Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

add sleep to bun shell builtins #13839

Draft
wants to merge 9 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/bun.js/api/Timer.zig
Original file line number Diff line number Diff line change
Expand Up @@ -730,6 +730,7 @@ pub const EventLoopTimer = struct {
TestRunner,
StatWatcherScheduler,
UpgradedDuplex,
ShellSleepBuiltin,

pub fn Type(comptime T: Tag) type {
return switch (T) {
Expand All @@ -738,6 +739,7 @@ pub const EventLoopTimer = struct {
.TestRunner => JSC.Jest.TestRunner,
.StatWatcherScheduler => StatWatcherScheduler,
.UpgradedDuplex => uws.UpgradedDuplex,
.ShellSleepBuiltin => bun.shell.Interpreter.Builtin.Sleep,
};
}
};
Expand Down Expand Up @@ -807,6 +809,10 @@ pub const EventLoopTimer = struct {
return .disarm;
}

if (comptime t.Type() == bun.shell.Interpreter.Builtin.Sleep) {
return container.onSleepFinish();
}

return container.callback(container);
},
}
Expand Down
162 changes: 162 additions & 0 deletions src/shell/interpreter.zig
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const windows = bun.windows;
const uv = windows.libuv;
const Maybe = JSC.Maybe;
const WTFStringImplStruct = @import("../string.zig").WTFStringImplStruct;
const timespec = bun.timespec;

const Pipe = [2]bun.FileDescriptor;
const shell = @import("./shell.zig");
Expand Down Expand Up @@ -5271,6 +5272,7 @@ pub const Interpreter = struct {
dirname: Dirname,
basename: Basename,
cp: Cp,
sleep: Sleep,
};

const Result = @import("../result.zig").Result;
Expand All @@ -5296,6 +5298,7 @@ pub const Interpreter = struct {
dirname,
basename,
cp,
sleep,

pub const DISABLED_ON_POSIX: []const Kind = &.{ .cat, .cp };

Expand Down Expand Up @@ -5324,6 +5327,7 @@ pub const Interpreter = struct {
.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",
.sleep => "usage: sleep seconds\n",
};
}

Expand Down Expand Up @@ -5502,6 +5506,7 @@ pub const Interpreter = struct {
.dirname => this.callImplWithType(Dirname, Ret, "dirname", field, args_),
.basename => this.callImplWithType(Basename, Ret, "basename", field, args_),
.cp => this.callImplWithType(Cp, Ret, "cp", field, args_),
.sleep => this.callImplWithType(Sleep, Ret, "sleep", field, args_),
};
}

Expand Down Expand Up @@ -10497,6 +10502,162 @@ pub const Interpreter = struct {
}
};

pub const Sleep = struct {
bltn: *Builtin,
state: enum { idle, err, done } = .idle,

event_loop_timer: JSC.BunTimer.EventLoopTimer = .{
.next = .{},
.tag = .ShellSleepBuiltin,
},

pub fn start(this: *Sleep) Maybe(void) {
const args = this.bltn.argsSlice();
var iter = bun.SliceIterator([*:0]const u8).init(args);

if (args.len == 0) return this.fail(Builtin.Kind.usageString(.sleep));

var total_seconds: f64 = 0;
while (iter.next()) |arg| {
invalid_interval: {
var trimmed: string = bun.strings.trimLeft(bun.sliceTo(arg, 0), " ");

if (trimmed.len == 0 or trimmed[0] == '-') {
break :invalid_interval;
}

const maybe_unit: ?enum {
seconds,
minutes,
hours,
days,

pub fn apply(unit: @This(), value: f64) f64 {
return switch (unit) {
.seconds => value,
.minutes => value * 60,
.hours => value * 60 * 60,
.days => value * 60 * 60 * 24,
};
}
} = switch (trimmed[trimmed.len - 1]) {
's' => .seconds,
'm' => .minutes,
'h' => .hours,
'd' => .days,
else => null,
};

if (maybe_unit != null) {
trimmed = trimmed[0 .. trimmed.len - 1];
}

const value = bun.fmt.parseFloat(f64, trimmed) catch {
break :invalid_interval;
};

const seconds = if (maybe_unit) |unit| unit.apply(value) else value;

if (std.math.isInf(seconds)) {
// if positive infinity is seen, set total seconds to `-1`.
// continue iterating to catch invalid args
total_seconds = -1;
} else if (std.math.isNan(seconds)) {
break :invalid_interval;
}

if (total_seconds != -1) {
total_seconds += seconds;
}

continue;
}

return this.fail("sleep: invalid time interval\n");
}

if (total_seconds != 0) {
switch (this.bltn.eventLoop()) {
.js => |js| {
const vm = js.getVmImpl();

const milliseconds: i64 = if (total_seconds == -1)
std.math.maxInt(i64)
else
@intFromFloat(@floor(total_seconds * @as(f64, std.time.ms_per_s)));

const sleep_time = timespec.msFromNow(milliseconds);

this.event_loop_timer.next = sleep_time;
this.event_loop_timer.tag = .ShellSleepBuiltin;
this.event_loop_timer.state = .ACTIVE;
vm.timer.insert(&this.event_loop_timer);
vm.timer.incrementTimerRef(1);
return Maybe(void).success;
},
.mini => |mini| {
_ = mini;
const sleep_time: u64 = if (total_seconds == -1)
std.math.maxInt(u64)
else
@intFromFloat(@floor(total_seconds * @as(f64, std.time.ns_per_s)));

std.time.sleep(sleep_time);
},
}
}

this.state = .done;
this.bltn.done(0);

return Maybe(void).success;
}

pub fn deinit(_: *Sleep) void {}

pub fn fail(this: *Sleep, msg: string) Maybe(void) {
if (this.bltn.stderr.needsIO()) |safeguard| {
this.state = .err;
this.bltn.stderr.enqueue(this, msg, safeguard);
return Maybe(void).success;
}
_ = this.bltn.writeNoIO(.stderr, msg);
this.bltn.done(1);
return Maybe(void).success;
}

pub fn onIOWriterChunk(this: *Sleep, _: usize, maybe_e: ?JSC.SystemError) void {
if (maybe_e) |e| {
defer e.deref();
this.state = .err;
this.bltn.done(1);
return;
}
if (this.state == .done) {
this.bltn.done(0);
}
if (this.state == .err) {
this.bltn.done(1);
}
}

pub fn onSleepFinish(this: *Sleep) JSC.BunTimer.EventLoopTimer.Arm {
switch (this.bltn.eventLoop()) {
.js => |js| {
const vm = js.getVmImpl();
this.event_loop_timer.state = .FIRED;
this.event_loop_timer.heap = .{};
this.bltn.done(0);
vm.timer.incrementTimerRef(-1);
},
.mini => |mini| {
_ = mini;
},
}
return .disarm;
}
};

pub const Cp = struct {
bltn: *Builtin,
opts: Opts = .{},
Expand Down Expand Up @@ -12280,6 +12441,7 @@ pub const IOWriterChildPtr = struct {
Interpreter.Builtin.Basename,
Interpreter.Builtin.Cp,
Interpreter.Builtin.Cp.ShellCpOutputTask,
Interpreter.Builtin.Sleep,
shell.subproc.PipeReader.CapturedWriter,
});

Expand Down
16 changes: 14 additions & 2 deletions src/string_immutable.zig
Original file line number Diff line number Diff line change
Expand Up @@ -4539,11 +4539,23 @@ pub fn trim(slice: anytype, comptime values_to_strip: []const u8) @TypeOf(slice)
var begin: usize = 0;
var end: usize = slice.len;

while (begin < end and std.mem.indexOfScalar(u8, values_to_strip, slice[begin]) != null) : (begin += 1) {}
while (end > begin and std.mem.indexOfScalar(u8, values_to_strip, slice[end - 1]) != null) : (end -= 1) {}
while (begin < end and indexOfChar(values_to_strip, slice[begin]) != null) : (begin += 1) {}
while (end > begin and indexOfChar(values_to_strip, slice[end - 1]) != null) : (end -= 1) {}
return slice[begin..end];
}

pub fn trimLeft(slice: anytype, comptime values_to_strip: []const u8) @TypeOf(slice) {
var begin: usize = 0;
while (begin < slice.len and indexOfChar(values_to_strip, slice[begin]) != null) : (begin += 1) {}
return slice[begin..];
}

pub fn trimRight(slice: anytype, comptime values_to_strip: []const u8) @TypeOf(slice) {
var end: usize = slice.len;
while (end > 0 and indexOfChar(values_to_strip, slice[end - 1]) != null) : (end -= 1) {}
return slice[0..end];
}

pub const whitespace_chars = [_]u8{ ' ', '\t', '\n', '\r', std.ascii.control_code.vt, std.ascii.control_code.ff };

pub fn lengthOfLeadingWhitespaceASCII(slice: string) usize {
Expand Down
53 changes: 53 additions & 0 deletions test/js/bun/shell/commands/sleep.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { describe } from "bun:test";
import { createTestBuilder } from "../test_builder";
const Builder = createTestBuilder(import.meta.path);

describe("sleep", async () => {
Builder.command`sleep`.exitCode(1).stdout("").stderr("usage: sleep seconds\n").runAsTest("prints usage");
Builder.command`sleep -1`
.exitCode(1)
.stdout("")
.stderr("sleep: invalid time interval\n")
.runAsTest("errors on negative values");
Builder.command`sleep j`
.exitCode(1)
.stdout("")
.stderr("sleep: invalid time interval\n")
.runAsTest("errors on non-numeric values");
Builder.command`sleep 1 j`
.exitCode(1)
.stdout("")
.stderr("sleep: invalid time interval\n")
.runAsTest("errors on any invalid values");

Builder.command`sleep 1`.exitCode(0).stdout("").stderr("").duration(1000).runAsTest("sleep works");

Builder.command`sleep ' 0.5'`.exitCode(0).stdout("").stderr("").duration(500).runAsTest("trims leading spaces");
Builder.command`sleep '.5 '`
.exitCode(1)
.stdout("")
.stderr("sleep: invalid time interval\n")
.runAsTest("does not trim trailing spaces");

Builder.command`sleep .5 .5`
.exitCode(0)
.stdout("")
.stderr("")
.duration(1000)
.runAsTest("sleeps for sum of arguments");

Builder.command`sleep .5s`.exitCode(0).stdout("").stderr("").duration(500).runAsTest("sleeps for seconds");
Builder.command`sleep ${1 / 60 / 2}m`.exitCode(0).stdout("").stderr("").duration(500).runAsTest("sleeps for minutes");
Builder.command`sleep ${1 / 60 / 60 / 2}h`
.exitCode(0)
.stdout("")
.stderr("")
.duration(500)
.runAsTest("sleeps for hours");
Builder.command`sleep ${1 / 60 / 60 / 24 / 2}d`
.exitCode(0)
.stdout("")
.stderr("")
.duration(500)
.runAsTest("sleeps for days");
});
18 changes: 15 additions & 3 deletions test/js/bun/shell/test_builder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export function createTestBuilder(path: string) {
file_equals: { [filename: string]: string | (() => string | Promise<string>) } = {};
_doesNotExist: string[] = [];
_timeout: number | undefined = undefined;
_expectedDuration: number | undefined = undefined;

tempdir: string | undefined = undefined;
_env: { [key: string]: string } | undefined = undefined;
Expand Down Expand Up @@ -213,8 +214,16 @@ export function createTestBuilder(path: string) {
return this;
}

async doChecks(stdout: Buffer, stderr: Buffer, exitCode: number): Promise<void> {
duration(ms: number): this {
this._expectedDuration = ms;
return this;
}

async doChecks(stdout: Buffer, stderr: Buffer, exitCode: number, durationMs: number): Promise<void> {
const tempdir = this.tempdir || "NO_TEMP_DIR";
if (this._expectedDuration !== undefined) {
expect(durationMs >= this._expectedDuration && durationMs <= this._expectedDuration + 200).toBeTrue();
}
if (this.expected_stdout !== undefined) {
if (typeof this.expected_stdout === "string") {
expect(stdout.toString()).toEqual(this.expected_stdout.replaceAll("$TEMP_DIR", tempdir));
Expand Down Expand Up @@ -255,24 +264,27 @@ export function createTestBuilder(path: string) {
return Promise.resolve(undefined);
}

const startTime = performance.now();
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 endTime = performance.now();

const { stdout, stderr, exitCode } = output;
await this.doChecks(stdout, stderr, exitCode);
await this.doChecks(stdout, stderr, exitCode, endTime - startTime);
} catch (err_) {
const endTime = performance.now();
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);
this.doChecks(stdout, stderr, exitCode, endTime - startTime);
return;
}
if (this.expected_error === true) return undefined;
Expand Down