From 7c42906563cab5fb5572d0aec65348fd97262edc Mon Sep 17 00:00:00 2001 From: matthewjcollins Date: Thu, 1 Oct 2026 11:48:41 -0400 Subject: [PATCH 1/5] fix teensytoany read failures after timeout --- HISTORY.md | 13 +++ teensytoany/__init__.py | 3 +- teensytoany/teensytoany.py | 70 ++++++++++++++++- teensytoany/tests/test_teensytoany.py | 109 ++++++++++++++++++++++++++ 4 files changed, 191 insertions(+), 4 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index b576241..5c114ea 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,18 @@ # History +## Unreleased + +* Keep replies in step with their commands when the reading thread is held up, + for example by another thread holding the GIL. pyserial's ``read_until`` + checks its deadline after every byte, so a late reader used to take one byte + of an on-time reply and leave the rest for the next command, after which + every command read the reply meant for another one. + - A reply cut off this way is now read to the end of the line. + - A missing, incomplete or unparseable reply now flushes the input buffer + and checks that a ``version`` round trip succeeds before raising + ``TeensyToAnyReplyError``, a subclass of ``RuntimeError``. A reply carrying + an error code from the device is raised as before, without flushing. + ## 0.14.0 (2025-09-05) * Provide support for teensytoany firmware 0.18.0 for ``spi_transfer16`` command. diff --git a/teensytoany/__init__.py b/teensytoany/__init__.py index 37f07cc..2e1778f 100644 --- a/teensytoany/__init__.py +++ b/teensytoany/__init__.py @@ -4,9 +4,10 @@ __email__ = 'info@ramonaoptics.com' from ._version import __version__ # noqa from .teensypower import TeensyPower -from .teensytoany import TeensyToAny +from .teensytoany import TeensyToAny, TeensyToAnyReplyError __all__ = [ 'TeensyToAny', + 'TeensyToAnyReplyError', 'TeensyPower', ] diff --git a/teensytoany/teensytoany.py b/teensytoany/teensytoany.py index 1854648..f9dce76 100644 --- a/teensytoany/teensytoany.py +++ b/teensytoany/teensytoany.py @@ -9,7 +9,15 @@ from serial import LF, Serial from serial.tools.list_ports import comports -__all__ = ['TeensyToAny'] +__all__ = ['TeensyToAny', 'TeensyToAnyReplyError'] + + +class TeensyToAnyReplyError(RuntimeError): + """A reply was missing, incomplete or could not be parsed. + + Before this is raised, the input buffer is flushed and a ``version`` + round trip checks that the next command will get its own reply. + """ class TeensyToAny: @@ -472,6 +480,17 @@ def _read(self, *, size=1024, decode=True) -> str: raise RuntimeError("Device must be opened first") data = self._serial.read_until(LF, size=size) + # read_until checks a single deadline after every byte, so a thread + # that is held up past it (for example while another thread holds the + # GIL) takes one byte of a reply that arrived on time and leaves the + # rest, which the next command would then read as its own reply. Keep + # reading while bytes keep arriving; only a read that gets nothing for + # a whole timeout means the reply really is incomplete. + while data and not data.endswith(LF) and len(data) < size: + more = self._serial.read_until(LF, size=size - len(data)) + if not more: + break + data += more if decode: data = data.decode() @@ -482,13 +501,24 @@ def _ask(self, data, *, size=1024, decode=True) -> str: self._write(data) returned = self._read(size=size, decode=decode) if len(returned) == 0: - raise RuntimeError(f"Failed to read a response for command: {data}") + raise self._reply_error(f"Failed to read a response for command: {data}") + if not returned.endswith('\n' if decode else LF): + # The rest of this reply may still arrive, and would be read as + # the next command's reply. + raise self._reply_error( + f"Incomplete response {returned!r} for command: {data}") returned_list = returned.split(' ', 1) error = returned_list[0] message = None if len(returned_list) == 1 else returned_list[1] - error = int(error) + try: + error = int(error) + except ValueError: + raise self._reply_error( + f"Unexpected response {returned!r} for command: {data}") from None if error != 0: + # The device answered this command with an error, so the replies + # are still in step: no need to resynchronize. if message is None: message = os.strerror(error) raise RuntimeError(f"Responded with Error Code {error}: {message}") @@ -497,6 +527,40 @@ def _ask(self, data, *, size=1024, decode=True) -> str: message = message.strip() return message + def _reply_error(self, description): + """Resynchronize after a bad reply, and describe what happened.""" + if self._resynchronize(): + recovery = "The input buffer was flushed and replies are back in step." + else: + recovery = ( + "The replies could not be brought back in step; " + "power cycle the device before using it again." + ) + return TeensyToAnyReplyError(f"{description}. {recovery}") + + def _resynchronize(self, *, attempts=5): + """Flush the input buffer until a ``version`` round trip succeeds. + + Commands the device has already queued keep producing replies after a + flush, so wait for a quiet period longer than any one command before + checking the round trip. + """ + quiet_period = max(0.5, 2 * self._timeout) + for _ in range(attempts): + self._serial.reset_input_buffer() + sleep(quiet_period) + if self._serial.in_waiting: + continue + self._write("version") + reply = self._read() + error, _, version = reply.partition(' ') + if not reply.endswith('\n') or error != '0': + continue + # While the device is being opened, its version isn't known yet. + if self._version is None or version.strip() == self._version: + return True + return False + def i2c_init(self, baud_rate: int=100_100, timeout=200_000, register_space=1): cmd = f"i2c_init {baud_rate:d} {timeout:d} {register_space:d}" self._ask(cmd) diff --git a/teensytoany/tests/test_teensytoany.py b/teensytoany/tests/test_teensytoany.py index da87e4c..45eed17 100644 --- a/teensytoany/tests/test_teensytoany.py +++ b/teensytoany/tests/test_teensytoany.py @@ -32,3 +32,112 @@ def test_nop_buffer_size_fail(i): with pytest.raises(Exception): # pylint: disable=protected-access t._ask("nop" + " " * (i + 1 - len("nop\n"))) + + +class FakeSerial: + """Hands back one scripted chunk per read_until call, then nothing.""" + + def __init__(self, chunks): + self.chunks = list(chunks) + self.written = [] + self.flushes = 0 + self.in_waiting = 0 + + def write(self, data): + self.written.append(data) + + def read_until(self, expected=b'\n', size=None): # pylint: disable=unused-argument + return self.chunks.pop(0) if self.chunks else b'' + + def reset_input_buffer(self): + self.flushes += 1 + + def close(self): + pass + + +@pytest.fixture(name='make_teensy') +def fixture_make_teensy(monkeypatch): + # Resynchronizing waits for the port to go quiet; there is nothing to wait + # for here. + monkeypatch.setattr('teensytoany.teensytoany.sleep', lambda seconds: None) + + def make_teensy(chunks, version='0.22.0'): + teensy = TeensyToAny(open=False) + # pylint: disable=protected-access + teensy._serial = FakeSerial(chunks) + teensy._version = version + return teensy + + return make_teensy + + +def test_read_finishes_reply_cut_off_by_late_wakeup(make_teensy): + teensy = make_teensy([b'0', b' 0x1840\n']) + # pylint: disable=protected-access + assert teensy._read() == '0 0x1840\n' + + +def test_read_leaves_next_reply_in_buffer(make_teensy): + teensy = make_teensy([b'0 1\n', b'0 2\n']) + # pylint: disable=protected-access + assert teensy._read() == '0 1\n' + assert teensy._serial.chunks == [b'0 2\n'] + + +def test_read_returns_partial_reply_when_nothing_more_arrives(make_teensy): + teensy = make_teensy([b'0', b'']) + # pylint: disable=protected-access + assert teensy._read() == '0' + + +def test_ask_returns_own_value_after_late_wakeup(make_teensy): + teensy = make_teensy([b'0', b' 0x1840\n']) + # pylint: disable=protected-access + assert teensy._ask('i2c_read_uint16 0x92 0x0') == '0x1840' + assert teensy._serial.written == [b'i2c_read_uint16 0x92 0x0\n'] + assert teensy._serial.flushes == 0 + + +@pytest.mark.parametrize('chunks', [ + [b''], # nothing within the timeout + [b'0', b''], # part of a reply, and nothing more + [b'\n'], # the end of another command's reply + [b'FC0\n'], # the value of another command's reply +], ids=['empty', 'incomplete', 'stray-newline', 'stray-value']) +def test_ask_resynchronizes_after_bad_reply(make_teensy, chunks): + teensy = make_teensy(chunks + [b'0 0.22.0\n']) + with pytest.raises(teensytoany.TeensyToAnyReplyError, match='back in step') as error: + # pylint: disable=protected-access + teensy._ask('gpio_digital_read 22') + assert isinstance(error.value, RuntimeError) + # pylint: disable=protected-access + assert teensy._serial.written == [b'gpio_digital_read 22\n', b'version\n'] + assert teensy._serial.flushes == 1 + + +def test_ask_device_error_does_not_resynchronize(make_teensy): + teensy = make_teensy([b'5\n']) + with pytest.raises(RuntimeError, match='Error Code 5') as error: + # pylint: disable=protected-access + teensy._ask('i2c_read_uint16 0x92 0x0') + assert not isinstance(error.value, teensytoany.TeensyToAnyReplyError) + # pylint: disable=protected-access + assert teensy._serial.flushes == 0 + + +def test_ask_says_to_power_cycle_when_resync_fails(make_teensy): + # Every version check gets some other command's reply. + teensy = make_teensy([b'\n'] + [b'0 0x1840\n'] * 5) + with pytest.raises(teensytoany.TeensyToAnyReplyError, match='power cycle'): + # pylint: disable=protected-access + teensy._ask('gpio_digital_read 22') + # pylint: disable=protected-access + assert teensy._serial.flushes == 5 + + +def test_resync_accepts_any_version_while_opening(make_teensy): + teensy = make_teensy([b'\n', b'0 0.9.0\n'], version=None) + with pytest.raises(teensytoany.TeensyToAnyReplyError, match='back in step'): + # pylint: disable=protected-access + teensy._ask('version') From f3c0b2af3a0c63a652dd4c3ab424d2d0126ac128 Mon Sep 17 00:00:00 2001 From: matthewjcollins Date: Thu, 1 Oct 2026 14:44:23 -0400 Subject: [PATCH 2/5] remove reply desync tests --- teensytoany/tests/test_teensytoany.py | 109 -------------------------- 1 file changed, 109 deletions(-) diff --git a/teensytoany/tests/test_teensytoany.py b/teensytoany/tests/test_teensytoany.py index 45eed17..da87e4c 100644 --- a/teensytoany/tests/test_teensytoany.py +++ b/teensytoany/tests/test_teensytoany.py @@ -32,112 +32,3 @@ def test_nop_buffer_size_fail(i): with pytest.raises(Exception): # pylint: disable=protected-access t._ask("nop" + " " * (i + 1 - len("nop\n"))) - - -class FakeSerial: - """Hands back one scripted chunk per read_until call, then nothing.""" - - def __init__(self, chunks): - self.chunks = list(chunks) - self.written = [] - self.flushes = 0 - self.in_waiting = 0 - - def write(self, data): - self.written.append(data) - - def read_until(self, expected=b'\n', size=None): # pylint: disable=unused-argument - return self.chunks.pop(0) if self.chunks else b'' - - def reset_input_buffer(self): - self.flushes += 1 - - def close(self): - pass - - -@pytest.fixture(name='make_teensy') -def fixture_make_teensy(monkeypatch): - # Resynchronizing waits for the port to go quiet; there is nothing to wait - # for here. - monkeypatch.setattr('teensytoany.teensytoany.sleep', lambda seconds: None) - - def make_teensy(chunks, version='0.22.0'): - teensy = TeensyToAny(open=False) - # pylint: disable=protected-access - teensy._serial = FakeSerial(chunks) - teensy._version = version - return teensy - - return make_teensy - - -def test_read_finishes_reply_cut_off_by_late_wakeup(make_teensy): - teensy = make_teensy([b'0', b' 0x1840\n']) - # pylint: disable=protected-access - assert teensy._read() == '0 0x1840\n' - - -def test_read_leaves_next_reply_in_buffer(make_teensy): - teensy = make_teensy([b'0 1\n', b'0 2\n']) - # pylint: disable=protected-access - assert teensy._read() == '0 1\n' - assert teensy._serial.chunks == [b'0 2\n'] - - -def test_read_returns_partial_reply_when_nothing_more_arrives(make_teensy): - teensy = make_teensy([b'0', b'']) - # pylint: disable=protected-access - assert teensy._read() == '0' - - -def test_ask_returns_own_value_after_late_wakeup(make_teensy): - teensy = make_teensy([b'0', b' 0x1840\n']) - # pylint: disable=protected-access - assert teensy._ask('i2c_read_uint16 0x92 0x0') == '0x1840' - assert teensy._serial.written == [b'i2c_read_uint16 0x92 0x0\n'] - assert teensy._serial.flushes == 0 - - -@pytest.mark.parametrize('chunks', [ - [b''], # nothing within the timeout - [b'0', b''], # part of a reply, and nothing more - [b'\n'], # the end of another command's reply - [b'FC0\n'], # the value of another command's reply -], ids=['empty', 'incomplete', 'stray-newline', 'stray-value']) -def test_ask_resynchronizes_after_bad_reply(make_teensy, chunks): - teensy = make_teensy(chunks + [b'0 0.22.0\n']) - with pytest.raises(teensytoany.TeensyToAnyReplyError, match='back in step') as error: - # pylint: disable=protected-access - teensy._ask('gpio_digital_read 22') - assert isinstance(error.value, RuntimeError) - # pylint: disable=protected-access - assert teensy._serial.written == [b'gpio_digital_read 22\n', b'version\n'] - assert teensy._serial.flushes == 1 - - -def test_ask_device_error_does_not_resynchronize(make_teensy): - teensy = make_teensy([b'5\n']) - with pytest.raises(RuntimeError, match='Error Code 5') as error: - # pylint: disable=protected-access - teensy._ask('i2c_read_uint16 0x92 0x0') - assert not isinstance(error.value, teensytoany.TeensyToAnyReplyError) - # pylint: disable=protected-access - assert teensy._serial.flushes == 0 - - -def test_ask_says_to_power_cycle_when_resync_fails(make_teensy): - # Every version check gets some other command's reply. - teensy = make_teensy([b'\n'] + [b'0 0x1840\n'] * 5) - with pytest.raises(teensytoany.TeensyToAnyReplyError, match='power cycle'): - # pylint: disable=protected-access - teensy._ask('gpio_digital_read 22') - # pylint: disable=protected-access - assert teensy._serial.flushes == 5 - - -def test_resync_accepts_any_version_while_opening(make_teensy): - teensy = make_teensy([b'\n', b'0 0.9.0\n'], version=None) - with pytest.raises(teensytoany.TeensyToAnyReplyError, match='back in step'): - # pylint: disable=protected-access - teensy._ask('version') From b959e7977e3b5c00688753afb3b9043ee0d99416 Mon Sep 17 00:00:00 2001 From: matthewjcollins Date: Thu, 1 Oct 2026 15:24:27 -0400 Subject: [PATCH 3/5] add per byte timeout --- HISTORY.md | 5 ++- teensytoany/teensytoany.py | 86 +++++++++++++++----------------------- 2 files changed, 37 insertions(+), 54 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index 5c114ea..a7e2424 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -7,7 +7,10 @@ checks its deadline after every byte, so a late reader used to take one byte of an on-time reply and leave the rest for the next command, after which every command read the reply meant for another one. - - A reply cut off this way is now read to the end of the line. + - Replies are now read with a timeout that restarts with every byte, so a + late reader still gets the rest of a reply that has already arrived. + - The input buffer is cleared before each command, dropping anything left + by a late reply to an earlier one. - A missing, incomplete or unparseable reply now flushes the input buffer and checks that a ``version`` round trip succeeds before raising ``TeensyToAnyReplyError``, a subclass of ``RuntimeError``. A reply carrying diff --git a/teensytoany/teensytoany.py b/teensytoany/teensytoany.py index f9dce76..2a22f5c 100644 --- a/teensytoany/teensytoany.py +++ b/teensytoany/teensytoany.py @@ -13,10 +13,10 @@ class TeensyToAnyReplyError(RuntimeError): - """A reply was missing, incomplete or could not be parsed. + """A reply was missing or could not be parsed. - Before this is raised, the input buffer is flushed and a ``version`` - round trip checks that the next command will get its own reply. + Before this is raised, the input buffer is flushed and a ``version`` round + trip checks that the next command will get its own reply. """ @@ -479,18 +479,16 @@ def _read(self, *, size=1024, decode=True) -> str: if self._serial is None: raise RuntimeError("Device must be opened first") - data = self._serial.read_until(LF, size=size) - # read_until checks a single deadline after every byte, so a thread - # that is held up past it (for example while another thread holds the - # GIL) takes one byte of a reply that arrived on time and leaves the - # rest, which the next command would then read as its own reply. Keep - # reading while bytes keep arriving; only a read that gets nothing for - # a whole timeout means the reply really is incomplete. - while data and not data.endswith(LF) and len(data) < size: - more = self._serial.read_until(LF, size=size - len(data)) - if not more: + # Unlike pyserial's read_until, which gives the whole line one deadline, + # the timeout restarts with every byte: a reader held up past the + # deadline still gets the rest of a reply that has already arrived. + data = bytearray() + while len(data) < size: + byte = self._serial.read(1) + data += byte + if not byte or byte == LF: break - data += more + data = bytes(data) if decode: data = data.decode() @@ -498,27 +496,26 @@ def _read(self, *, size=1024, decode=True) -> str: return data def _ask(self, data, *, size=1024, decode=True) -> str: + # Drop anything a late reply to an earlier command left behind. + self._serial.reset_input_buffer() self._write(data) returned = self._read(size=size, decode=decode) - if len(returned) == 0: - raise self._reply_error(f"Failed to read a response for command: {data}") - if not returned.endswith('\n' if decode else LF): - # The rest of this reply may still arrive, and would be read as - # the next command's reply. - raise self._reply_error( - f"Incomplete response {returned!r} for command: {data}") - returned_list = returned.split(' ', 1) - error = returned_list[0] message = None if len(returned_list) == 1 else returned_list[1] - try: - error = int(error) - except ValueError: - raise self._reply_error( - f"Unexpected response {returned!r} for command: {data}") from None + error = None + if returned.endswith('\n'): + try: + error = int(returned_list[0]) + except ValueError: + pass + if error is None: + # The reply may still be on its way, or belong to another command. + recovery = ("" if self._resynchronize() else + "; replies are out of step, power cycle the device") + raise TeensyToAnyReplyError( + f"Failed to read a response for command: {data} " + f"(got {returned!r}){recovery}") if error != 0: - # The device answered this command with an error, so the replies - # are still in step: no need to resynchronize. if message is None: message = os.strerror(error) raise RuntimeError(f"Responded with Error Code {error}: {message}") @@ -527,37 +524,20 @@ def _ask(self, data, *, size=1024, decode=True) -> str: message = message.strip() return message - def _reply_error(self, description): - """Resynchronize after a bad reply, and describe what happened.""" - if self._resynchronize(): - recovery = "The input buffer was flushed and replies are back in step." - else: - recovery = ( - "The replies could not be brought back in step; " - "power cycle the device before using it again." - ) - return TeensyToAnyReplyError(f"{description}. {recovery}") - def _resynchronize(self, *, attempts=5): - """Flush the input buffer until a ``version`` round trip succeeds. - - Commands the device has already queued keep producing replies after a - flush, so wait for a quiet period longer than any one command before - checking the round trip. - """ - quiet_period = max(0.5, 2 * self._timeout) + # Commands the device has queued keep replying after a flush, so wait + # for the port to go quiet before checking a version round trip. for _ in range(attempts): self._serial.reset_input_buffer() - sleep(quiet_period) + sleep(max(0.5, 2 * self._timeout)) if self._serial.in_waiting: continue + # The reply to version is known, so getting it back proves replies are in step. self._write("version") reply = self._read() - error, _, version = reply.partition(' ') - if not reply.endswith('\n') or error != '0': - continue # While the device is being opened, its version isn't known yet. - if self._version is None or version.strip() == self._version: + if (reply.startswith('0 ') and reply.endswith('\n') + and self._version in (None, reply[2:].strip())): return True return False From bb941fe2233875d5ffcb763c538e57330a000799 Mon Sep 17 00:00:00 2001 From: matthewjcollins Date: Thu, 1 Oct 2026 17:17:29 -0400 Subject: [PATCH 4/5] clarify comments --- teensytoany/teensytoany.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/teensytoany/teensytoany.py b/teensytoany/teensytoany.py index 2a22f5c..7910abc 100644 --- a/teensytoany/teensytoany.py +++ b/teensytoany/teensytoany.py @@ -509,7 +509,8 @@ def _ask(self, data, *, size=1024, decode=True) -> str: except ValueError: pass if error is None: - # The reply may still be on its way, or belong to another command. + # Nothing arrived in time, or not a whole reply: anything still on its way is + # dropped before the next command. recovery = ("" if self._resynchronize() else "; replies are out of step, power cycle the device") raise TeensyToAnyReplyError( From 977e14c01941c16fa0fd9409a9310208aec3c3ac Mon Sep 17 00:00:00 2001 From: matthewjcollins Date: Fri, 2 Oct 2026 13:22:24 -0400 Subject: [PATCH 5/5] Add read_until helper, warn on unparseable replies, release 0.15.0 --- HISTORY.md | 4 +--- teensytoany/teensytoany.py | 38 ++++++++++++++++++++++++-------------- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/HISTORY.md b/HISTORY.md index a7e2424..79f2eb5 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,6 +1,6 @@ # History -## Unreleased +## 0.15.0 (2026-10-02) * Keep replies in step with their commands when the reading thread is held up, for example by another thread holding the GIL. pyserial's ``read_until`` @@ -9,8 +9,6 @@ every command read the reply meant for another one. - Replies are now read with a timeout that restarts with every byte, so a late reader still gets the rest of a reply that has already arrived. - - The input buffer is cleared before each command, dropping anything left - by a late reply to an earlier one. - A missing, incomplete or unparseable reply now flushes the input buffer and checks that a ``version`` round trip succeeds before raising ``TeensyToAnyReplyError``, a subclass of ``RuntimeError``. A reply carrying diff --git a/teensytoany/teensytoany.py b/teensytoany/teensytoany.py index 7910abc..50d2e8f 100644 --- a/teensytoany/teensytoany.py +++ b/teensytoany/teensytoany.py @@ -20,6 +20,23 @@ class TeensyToAnyReplyError(RuntimeError): """ +def read_until(serial, expected, size): + """Read from ``serial`` until ``expected`` arrives or ``size`` bytes are read. + + Unlike pyserial's ``read_until``, which gives the whole line one deadline, + the timeout restarts with every byte: a reader held up past the deadline + still gets the rest of a reply that has already arrived. Stops early if no + byte arrives within the port's timeout. + """ + data = bytearray() + while len(data) < size: + byte = serial.read(1) + data += byte + if not byte or byte == expected: + break + return bytes(data) + + class TeensyToAny: # I've noticed that this is extremely slow. Simply asking the device # for the version number seems to take 100 ms exactly. @@ -479,16 +496,7 @@ def _read(self, *, size=1024, decode=True) -> str: if self._serial is None: raise RuntimeError("Device must be opened first") - # Unlike pyserial's read_until, which gives the whole line one deadline, - # the timeout restarts with every byte: a reader held up past the - # deadline still gets the rest of a reply that has already arrived. - data = bytearray() - while len(data) < size: - byte = self._serial.read(1) - data += byte - if not byte or byte == LF: - break - data = bytes(data) + data = read_until(self._serial, LF, size=size) if decode: data = data.decode() @@ -496,8 +504,6 @@ def _read(self, *, size=1024, decode=True) -> str: return data def _ask(self, data, *, size=1024, decode=True) -> str: - # Drop anything a late reply to an earlier command left behind. - self._serial.reset_input_buffer() self._write(data) returned = self._read(size=size, decode=decode) returned_list = returned.split(' ', 1) @@ -506,8 +512,12 @@ def _ask(self, data, *, size=1024, decode=True) -> str: if returned.endswith('\n'): try: error = int(returned_list[0]) - except ValueError: - pass + except ValueError as e: + warn( + f"Received erroneous response code({e}), " + "ignoring the error and will resynchronize the communication.", + stacklevel=2 + ) if error is None: # Nothing arrived in time, or not a whole reply: anything still on its way is # dropped before the next command.