Skip to content

Commit

Permalink
Merge pull request #79 from zombocom/schneems/background-stdin
Browse files Browse the repository at this point in the history
Add ability to write to STDIN
  • Loading branch information
schneems authored Nov 30, 2024
2 parents 8cb790e + cb4429a commit 5b13cc8
Show file tree
Hide file tree
Showing 9 changed files with 213 additions and 11 deletions.
20 changes: 19 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
## HEAD

## 4.0.0

- Add: Rundoc command `background.stdin_write` to send a string to a backtround process' STDIN. This allows driving REPL interfaces (https://github.com/zombocom/rundoc/pull/79)

```
:::>- background.start("ruby #{script}",
name: "script",
wait: ">",
timeout: 15
)
:::-- background.stdin_write("hello", name: "script", wait: "hello")
:::-- background.stdin_write("exit", name: "script", wait: "exit")
:::>> background.stop(name: "script")
```

- Changed: Rundoc command `background.stop` now outputs the log contents on `:::>>`. Previously it output nothing (https://github.com/zombocom/rundoc/pull/79)
- Changed: Strings passed into `background.wait` and similar `wait:` arguments of other background rundoc commands were accidentally being converted into regex, now they are matched as string literals. If you need regexes, please open an issue, they could be supporte but require peg_parser support (https://github.com/zombocom/rundoc/pull/79)

## 3.1.2

- Fix: Using `rundoc.require` inside of a document that was `rundoc.require`-d now sources files from the correct relative document path (https://github.com/zombocom/rundoc/pull/84)
Expand All @@ -11,7 +29,7 @@

## 3.1.0

- Add: `--with-contents` flag that accepts a directory. The **contents** of the directory (and not the directory itself) will be copied into the working dir before execution. This is useful for debugging a single rundoc step. ()
- Add: `--with-contents` flag that accepts a directory. The **contents** of the directory (and not the directory itself) will be copied into the working dir before execution. This is useful for debugging a single rundoc step. (https://github.com/zombocom/rundoc/pull/83)

For example if `RUNDOC.md` features many smaller docs:

Expand Down
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,9 +86,10 @@ This will generate a project folder with your project in it, and a markdown `REA
- Boot background processes such as a local server
- [background.start](#background)
- [background.stop](#background)
- [background.stdin_write](#background)
- [background.wait](#background)
- [background.log.read](#background)
- [background.log.clear](#background)
- [background.wait](#background)
- Take screenshots
- [website.visit](#screenshots)
- [website.nav](#screenshots)
Expand Down Expand Up @@ -424,6 +425,21 @@ You can make the background process wait until it receives a certain string in t
:::>> background.start("rails server", name: "server", wait: "Listening on")
```

You can send strings to the STDIN of the process:

```
:::>> background.start("heroku run bash", name: "heroku_run", wait: "$")
:::-- background.stdin_write("ls", name: "heroku_run")
```

- Arguments
- contents: Positional. The string to write to stdin
- ending: A string to append to the end of the contents, defaults to a newline
you could set it to something like ";" or an empty newline ""
- name
- wait
- timeout

You can stop the process by referencing the name:

```
Expand All @@ -433,6 +449,7 @@ You can stop the process by referencing the name:
- Arguments
- name


You can also get the log contents:

```
Expand Down
1 change: 1 addition & 0 deletions lib/rundoc/code_command/background.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ class Rundoc::CodeCommand::Background
require "rundoc/code_command/background/wait"
require "rundoc/code_command/background/log/clear"
require "rundoc/code_command/background/log/read"
require "rundoc/code_command/background/stdin_write"
53 changes: 49 additions & 4 deletions lib/rundoc/code_command/background/process_spawn.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ class Rundoc::CodeCommand::Background
# server.stop
# server.alive? # => false
#
#
# There are class level methods that can be used to "name" and record
# background processes. They can be used like this:
#
Expand Down Expand Up @@ -53,17 +52,31 @@ def initialize(command, timeout: 5, log: Tempfile.new("log"), out: "2>&1")
@log = Pathname.new(log)
@log.dirname.mkpath
FileUtils.touch(@log)
@pipe_output, @pipe_input = IO.pipe

@command = "/usr/bin/env bash -c #{@command.shellescape} >> #{@log} #{out}"
@pid = nil
end

def wait(wait_value = nil, timeout_value = @timeout_value)
# Wait until a given string is found in the logs
#
# If the string is not found within the timeout, a Timeout::Error is raised
#
# Caution: The logs will not be cleared before waiting, so if the string is
# already present from a prior operation, then it will not wait at all.
#
# To ensure you're waiting for a brand new string, call `log.truncate(0)` first.
# which is accessible via `:::-- background.log.clear` in rundoc syntax.
#
# @param wait_value [String] the string to wait for
# @param timeout_value [Integer] the number of seconds to wait before raising a Timeout::Error
# @param file [Pathname] the file to read from, default is the log file
def wait(wait_value = nil, timeout_value = @timeout_value, file: @log)
call
return unless wait_value

Timeout.timeout(Integer(timeout_value)) do
until @log.read.match(wait_value)
until file.read.include?(wait_value)
sleep 0.01
end
end
Expand All @@ -78,18 +91,50 @@ def alive?
false
end

# Writes the contents along with an optional ending character to the STDIN of the backtround process
#
# @param contents [String] the contents to write to the STDIN of the background process
# @param ending [String] an optional string to append to the contents before writing default is a newline
# if you don't want an ending, pass `""`
# @param timeout [Integer] the number of seconds to wait before raising a Timeout::Error when writing to STDIN
# or waiting for a string to appear in the logs. That means that the process can wait for a maximum
# of `timeout * 2` seconds before raising a Timeout::Error.
# @param wait [String] the string to wait for in the logs before continuing. There's a race condition
# if the process is in the middle of printing out something to the logs, then the output
# you're waiting form might not come as a result of the stdin_write.
def stdin_write(contents, ending: $/, timeout: timeout_value, wait: nil)
log_file = File.new(@log)
before_write_bytes = log_file.size
begin
Timeout.timeout(Integer(timeout)) do
@pipe_input.print(contents + ending)
@pipe_input.flush
end
rescue Timeout::Error
raise "Timeout (#{timeout}s) waiting to write #{contents} to stdin. Log contents:\n'#{log.read}'"
end

# Ignore bytes written before we sent the STDIN message
log_file.seek(before_write_bytes)
wait(wait, timeout, file: log_file)
contents
end

def stop
return unless alive?
@pipe_input.close
Process.kill("TERM", -Process.getpgid(@pid))
Process.wait(@pid)
rescue Errno::ESRCH => e
puts "Error stopping process (command: #{command}): #{e}"
end

def check_alive!
raise "#{@original_command} has exited unexpectedly: #{@log.read}" unless alive?
end

private def call
@pid ||= Process.spawn(@command, pgroup: true)
@pid ||= Process.spawn(@command, pgroup: true, in: @pipe_output)
end
end
end
36 changes: 36 additions & 0 deletions lib/rundoc/code_command/background/stdin_write.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
class Rundoc::CodeCommand::Background
# Will send contents to the background process via STDIN along with a newline
#
#
class StdinWrite < Rundoc::CodeCommand
def initialize(contents, name:, wait:, timeout: 5, ending: $/)
@contents = contents
@ending = ending
@spawn = Rundoc::CodeCommand::Background::ProcessSpawn.find(name)
@wait = wait
@timeout_value = Integer(timeout)
@contents_written = nil
end

# The command is rendered (`:::>-`) by the output of the `def call` method.
def to_md(env = {})
writecontents
end

# The contents produced by the command (`:::->`) are rendered by the `def to_md` method.
def call(env = {})
writecontents
@spawn.log.read
end

def writecontents
@contents_written ||= @spawn.stdin_write(
contents,
wait: @wait,
ending: @ending,
timeout: @timeout_value
)
end
end
end
Rundoc.register_code_command(:"background.stdin_write", Rundoc::CodeCommand::Background::StdinWrite)
2 changes: 1 addition & 1 deletion lib/rundoc/code_command/background/stop.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ def to_md(env = {})

def call(env = {})
@spawn.stop
""
@spawn.log.read
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion lib/rundoc/version.rb
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
module Rundoc
VERSION = "3.1.2"
VERSION = "4.0.0"
end
57 changes: 57 additions & 0 deletions test/integration/background_stdin_test.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
require "test_helper"

class BackgroundStdinTest < Minitest::Test
def test_background_stdin_write
Dir.mktmpdir do |dir|
Dir.chdir(dir) do
dir = Pathname(dir)
script = dir.join("script.rb")
script.write <<~'EOF'
$stdout.sync = true
print "> "
while line = gets
puts line
if line.strip == "exit"
puts "Bye"
return
else
puts "You said: #{line}"
end
print "> "
end
EOF

source_path = dir.join("RUNDOC.md")
source_path.write <<~EOF
```
:::>- background.start("ruby #{script}",
name: "script",
wait: ">",
timeout: 15
)
:::-- background.stdin_write("hello", name: "script", wait: "hello")
:::-- background.stdin_write("exit", name: "script", wait: "exit")
:::>> background.stop(name: "script")
```
EOF

io = StringIO.new
Rundoc::CLI.new(
io: io,
source_path: source_path,
on_success_dir: dir.join(SUCCESS_DIRNAME)
).call

readme = dir.join(SUCCESS_DIRNAME).join("README.md").read
expected = <<~EOF
> hello
You said: hello
> exit
Bye
EOF
assert readme.include?(expected)
end
end
end
end
34 changes: 31 additions & 3 deletions test/rundoc/code_commands/background_test.rb
Original file line number Diff line number Diff line change
@@ -1,11 +1,39 @@
require "test_helper"

class BackgroundTest < Minitest::Test
def test_stdin_with_cat_echo
Dir.mktmpdir do |dir|
Dir.chdir(dir) do
background_start = Rundoc::CodeCommand::Background::Start.new("cat",
name: "cat")
background_start.call

output = Rundoc::CodeCommand::Background::StdinWrite.new(
"hello there",
name: "cat",
wait: "hello"
).call
assert_equal("hello there" + $/, output)

Rundoc::CodeCommand::Background::Log::Clear.new(
name: "cat"
).call

output = Rundoc::CodeCommand::Background::StdinWrite.new(
"general kenobi",
name: "cat",
wait: "general"
).call
assert_equal("general kenobi" + $/, output)
end
end
end

def test_process_spawn_gc
Dir.mktmpdir do |dir|
Dir.chdir(dir) do
file = "foo.txt"
`echo 'foo' >> #{file}`
run!("echo 'foo' >> #{file}")

background_start = Rundoc::CodeCommand::Background::Start.new("tail -f #{file}",
name: "tail2",
Expand All @@ -30,7 +58,7 @@ def test_background_start
Dir.mktmpdir do |dir|
Dir.chdir(dir) do
file = "foo.txt"
`echo 'foo' >> #{file}`
run!("echo 'foo' >> #{file}")

background_start = Rundoc::CodeCommand::Background::Start.new("tail -f #{file}",
name: "tail",
Expand All @@ -49,7 +77,7 @@ def test_background_start
output = log_clear.call
assert_equal("", output)

`echo 'bar' >> #{file}`
run!("echo 'bar' >> #{file}")

log_read = Rundoc::CodeCommand::Background::Log::Read.new(name: "tail")
output = log_read.call
Expand Down

0 comments on commit 5b13cc8

Please sign in to comment.