-
Notifications
You must be signed in to change notification settings - Fork 7
Dedicate the line-by-line reader from FD a separate method #186
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
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| """ | ||
| Helper utilities for the resalloc server. | ||
| """ | ||
|
|
||
| import os | ||
| import select | ||
| import time | ||
|
|
||
|
|
||
| class LineBufferOverflow(Exception): | ||
| """Raised when a line exceeds the maximum allowed length.""" | ||
|
|
||
|
|
||
| def yield_lines_from_fds(fds, timeout=None, max_line_length=None): | ||
| """ | ||
| Generator that reads lines from file descriptors, yielding (fd, line) pairs. | ||
| Raises TimeoutError when the deadline is exceeded. | ||
| Raises LineBufferOverflow when a line exceeds max_line_length. | ||
| """ | ||
| timeout = timeout or 60 * 60 * 24 | ||
| deadline = time.monotonic() + timeout | ||
| buffers = {fd: b"" for fd in fds} | ||
| active_fds = set(fds) | ||
|
|
||
| while active_fds: | ||
| remaining = deadline - time.monotonic() | ||
| if remaining <= 0: | ||
| raise TimeoutError(f"timeouted after {timeout}s") | ||
|
|
||
| ready, _, _ = select.select(list(active_fds), [], [], remaining) | ||
| if not ready: | ||
| raise TimeoutError(f"timeouted after {timeout}s (on read)") | ||
|
|
||
| for fd in ready: | ||
| if max_line_length: | ||
| capacity = max_line_length - len(buffers[fd]) | ||
| if capacity <= 0: | ||
| raise LineBufferOverflow( | ||
| f"line on fd {fd} exceeded {max_line_length} bytes") | ||
| chunk = os.read(fd, min(4096, capacity)) | ||
| else: | ||
| chunk = os.read(fd, 4096) | ||
|
|
||
| if chunk: | ||
| buffers[fd] += chunk | ||
| while b'\n' in buffers[fd]: | ||
| line, buffers[fd] = buffers[fd].split(b'\n', 1) | ||
| yield (fd, line + b'\n') | ||
| continue | ||
|
|
||
| # EOF | ||
| if buffers[fd]: | ||
| yield (fd, buffers[fd]) | ||
| active_fds.discard(fd) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| """ | ||
| Tests for yield_lines_from_fds() | ||
| """ | ||
|
|
||
| import os | ||
| import subprocess | ||
| import time | ||
|
|
||
| import pytest | ||
|
|
||
| from resallocserver.helpers import yield_lines_from_fds, LineBufferOverflow | ||
|
|
||
|
|
||
| def _pipe_with_data(data): | ||
| """Create a pipe, write data to it, close the write end, return read fd.""" | ||
| r, w = os.pipe() | ||
| os.write(w, data) | ||
| os.close(w) | ||
| return r | ||
|
|
||
|
|
||
| class TestYieldLinesFromFds: | ||
|
|
||
| def test_single_line(self): | ||
| fd = _pipe_with_data(b"hello\n") | ||
| result = list(yield_lines_from_fds([fd])) | ||
| assert result == [(fd, b"hello\n")] | ||
|
|
||
| def test_multiple_lines(self): | ||
| fd = _pipe_with_data(b"one\ntwo\nthree\n") | ||
| result = list(yield_lines_from_fds([fd])) | ||
| assert result == [ | ||
| (fd, b"one\n"), | ||
| (fd, b"two\n"), | ||
| (fd, b"three\n"), | ||
| ] | ||
|
|
||
| def test_partial_line_at_eof(self): | ||
| fd = _pipe_with_data(b"no newline") | ||
| result = list(yield_lines_from_fds([fd])) | ||
| assert result == [(fd, b"no newline")] | ||
|
|
||
| def test_partial_line_after_complete_lines(self): | ||
| fd = _pipe_with_data(b"first\nsecond") | ||
| result = list(yield_lines_from_fds([fd])) | ||
| assert result == [ | ||
| (fd, b"first\n"), | ||
| (fd, b"second"), | ||
| ] | ||
|
|
||
| def test_empty_input(self): | ||
| fd = _pipe_with_data(b"") | ||
| result = list(yield_lines_from_fds([fd])) | ||
| assert not result | ||
|
|
||
| def test_empty_lines(self): | ||
| fd = _pipe_with_data(b"\n\n\n") | ||
| result = list(yield_lines_from_fds([fd])) | ||
| assert result == [ | ||
| (fd, b"\n"), | ||
| (fd, b"\n"), | ||
| (fd, b"\n"), | ||
| ] | ||
|
|
||
| def test_multiple_fds(self): | ||
| fd1 = _pipe_with_data(b"from-fd1\n") | ||
| fd2 = _pipe_with_data(b"from-fd2\n") | ||
| result = list(yield_lines_from_fds([fd1, fd2])) | ||
| assert set(result) == { | ||
| (fd1, b"from-fd1\n"), | ||
| (fd2, b"from-fd2\n"), | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure if this works 100% as expected. When I have this #!/usr/bin/python3 -u
import sys
sys.stdout.write("LINE1\n")
sys.stderr.write("LINE2\n")
sys.stdout.write("LINE3\n")
sys.stderr.write("LINE4\n")then I am getting this in (Notice the order) Also, the server is trying to delete only two resources: I suppose the I don't know how much of this is intended or unexpected.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is this a regression by this PR? I doubt because we continue handle the stdout/stderr differently. We would have to care about flushing, and even after that I'm not really sure it would be guaranteed to not mix.
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not a regression. I was trying to implement it in my PR but ran out of patience, threw all the changes away and kept reading only STDOUT. But when I was reading this test, I thought maybe you are trying to implement support for both STDOUT and STDERR at the same time. It seems like that was not the intention, which is absolutely fine by me :-)
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding test_interleaved_stdout_stderr ... |
||
|
|
||
| def test_timeout_on_read(self): | ||
| r, w = os.pipe() | ||
| try: | ||
| with pytest.raises(TimeoutError, match="on read"): | ||
| list(yield_lines_from_fds([r], timeout=0.1)) | ||
| finally: | ||
| os.close(r) | ||
| os.close(w) | ||
|
|
||
| def test_timeout_deadline_expired(self): | ||
| r, w = os.pipe() | ||
| os.write(w, b"line\n") | ||
| try: | ||
| with pytest.raises(TimeoutError, match="timeouted after"): | ||
| for _ in yield_lines_from_fds([r], timeout=0.01): | ||
| time.sleep(0.05) | ||
| finally: | ||
| os.close(r) | ||
| os.close(w) | ||
|
|
||
| def test_max_line_length_ok(self): | ||
| fd = _pipe_with_data(b"short\n") | ||
| result = list(yield_lines_from_fds([fd], max_line_length=1000)) | ||
| assert result == [(fd, b"short\n")] | ||
|
|
||
| def test_max_line_length_exceeded(self): | ||
| fd = _pipe_with_data(b"a" * 300 + b"\n") | ||
| with pytest.raises(LineBufferOverflow, match="exceeded 200 bytes"): | ||
| list(yield_lines_from_fds([fd], max_line_length=200)) | ||
|
|
||
| def test_max_line_length_exact_boundary(self): | ||
| fd = _pipe_with_data(b"a" * 199 + b"\n") | ||
| result = list(yield_lines_from_fds([fd], max_line_length=200)) | ||
| assert len(result) == 1 | ||
|
|
||
| def test_max_line_length_no_newline(self): | ||
| fd = _pipe_with_data(b"a" * 300) | ||
| with pytest.raises(LineBufferOverflow, match="exceeded 200 bytes"): | ||
| list(yield_lines_from_fds([fd], max_line_length=200)) | ||
|
|
||
| def test_max_line_length_short_lines_pass(self): | ||
| fd = _pipe_with_data(b"ok\n" * 100) | ||
| result = list(yield_lines_from_fds([fd], max_line_length=10)) | ||
| assert len(result) == 100 | ||
|
|
||
| def test_large_line_across_chunks(self): | ||
| """Line larger than 4096 read buffer works when no limit set.""" | ||
| fd = _pipe_with_data(b"x" * 10000 + b"\n") | ||
| result = list(yield_lines_from_fds([fd])) | ||
| assert result == [(fd, b"x" * 10000 + b"\n")] | ||
|
|
||
| def test_binary_data(self): | ||
| data = bytes(range(256)).replace(b"\n", b"\x00") + b"\n" | ||
| fd = _pipe_with_data(data) | ||
| result = list(yield_lines_from_fds([fd])) | ||
| assert len(result) == 1 | ||
| assert result[0][1] == data | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Can you please add two more tests? One trivial, when the pipe has no data: fd = _pipe_with_data(b"")It works, I tested manually, but I'd like to have a test that the read doesn't hang forever (a naive implementation would IIRC hang forever) And very importantly, a test for the case that crashed our production the last time. It was basically this: #!/usr/bin/python3
import time
time.sleep(99999)So no output, but hanged forever. We need to make sure it timeouts correctly. It does, I tested manually, but having a unit test would be really nice.
Owner
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. test_empty_input + test_timeout_deadline_expired is basically the thing you want? |
||
|
|
||
| def test_two_fds_lines_not_mixed(self): | ||
| """Lines from two fds are attributed correctly, not interleaved.""" | ||
| fd1 = _pipe_with_data(b"aaa\nbbb\nccc\n") | ||
| fd2 = _pipe_with_data(b"111\n222\n333\n") | ||
| result = list(yield_lines_from_fds([fd1, fd2])) | ||
| fd1_lines = [line for fd, line in result if fd == fd1] | ||
| fd2_lines = [line for fd, line in result if fd == fd2] | ||
| assert fd1_lines == [b"aaa\n", b"bbb\n", b"ccc\n"] | ||
| assert fd2_lines == [b"111\n", b"222\n", b"333\n"] | ||
|
|
||
| def test_interleaved_stdout_stderr(self): | ||
| """Subprocess alternates stdout/stderr writes; lines stay attributed.""" | ||
| script = ( | ||
| "import sys\n" | ||
| "for i in range(5):\n" | ||
| " out = sys.stdout if i % 2 == 0 else sys.stderr\n" | ||
| " out.write(f'{out.name}:{i}\\n')\n" | ||
| " out.flush()\n" | ||
| ) | ||
| with subprocess.Popen( | ||
| ["python3", "-c", script], | ||
| stdout=subprocess.PIPE, | ||
| stderr=subprocess.PIPE, | ||
| ) as sp: | ||
| stdout_fd = sp.stdout.fileno() | ||
| stderr_fd = sp.stderr.fileno() | ||
| result = list(yield_lines_from_fds([stdout_fd, stderr_fd])) | ||
|
|
||
| stdout_lines = [line for fd, line in result if fd == stdout_fd] | ||
| stderr_lines = [line for fd, line in result if fd == stderr_fd] | ||
| assert stdout_lines == [b"<stdout>:0\n", b"<stdout>:2\n", b"<stdout>:4\n"] | ||
| assert stderr_lines == [b"<stderr>:1\n", b"<stderr>:3\n"] | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is a change of behavior from PR #182, so I'll explain the reasoning that we had with @nikromen.
This is a generic function, and we thought it probably depends on the situation how we want to treat partial lines. When processing some output just so that we can log it and display it to the users, for example, we probably want to read and show as much as possible. In such case, this would be the desired
assert.However, in the case of our
cmd_list, the partial line might be missing more than just the\nand therefore the resource name might be mangled. Trying to delete it might lead to errors that such a resource doesn't exist, or worst-case-scenario, deleting a different resource.I don't think it is a big concern, because this is probably a rare corner case, which probably can't even happen in our resalloc setup. But I wanted to point out the change.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I actually did the behavior change intentionally, because I didn't see a practical use for the other approach (and if, detecting missing
\nshould be easy on the caller's side).We never yield incomplete lines? Only if it is the last one (missing one before EOF). And unless there's a timeout, it means the file was successfully read from beginning to the end. I am probably missing something important here, please elaborate (we can have a meeting if you think it is worth it).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can talk about it on a meeting, it's likely that I am missing something, not you.
Agreed. And that's definitely a better place where to handle it. So +1 to this change, I see no problem with it, I just wanted to point out the difference so we are all aware of it.