Conversation
Client: st lib/st has no CI, so nothing runs its tests. It has two SUnit suites - TProtocolStringSizeLimitTest, added with the string size limit, and TProtocolRecursionDepthTest, added with the recursion depth limit - and until now neither had ever been run by anything but the person who wrote them. The binding cannot be wired up the way the others are. It is not in configure.ac and not in a SUBDIRS, so there is no "make check" to hang a job on: lib/st is a Squeak/Pharo file-in and nothing else. GNU Smalltalk is no help either, because it cannot parse the chunk format thrift.st is written in. Pharo files it in cleanly, so that is what the job uses. lib/st/test/run-tests.sh does the work, so that a developer runs the same thing CI does: it files thrift.st and the suites into a Pharo image, generates the code TProtocolRecursionDepthTest needs, runs both suites and reports. The script has to do its own asserting. Pharo reports a failed test on stdout and still exits 0, so the exit status says nothing; the run and pass counts are the only signal there is. It checks the number of tests that ran as well as the number that passed, because a suite that fails to file in registers no tests at all and would otherwise report "RUN=0 PASS=0 FAIL=0" and read as success. Both were verified by mutation: removing the checkStringSize: call from readString turns 11 passes into 6 passes, 4 failures and 1 error, and a wrong expected count is reported as such. The job pins the Pharo release line rather than following "stable", which will move to Pharo 14 one day and turn the job red for reasons that have nothing to do with Thrift. Current state, on an unmodified tree: 11 of 11 and 6 of 6. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Client: st
TTransport>>readAll: asks read: for the bytes it still needs and
appends whatever comes back, until it has enough:
[str size < anInteger] whileTrue:
[str nextPutAll: (self read: anInteger - str size)]
A read: that answers an empty string appends nothing, and asking the
same source again for the same bytes cannot answer differently, so the
loop has no way to end.
Nothing in thrift.st answers an empty string from read: today -
TSocket>>read: signals instead - so this is not reachable as the library
stands. It is a contract any new or third-party transport can break, and
breaking it hangs the caller rather than raising. c_glib had the same
shape in thrift_transport_real_read_all() and was given an explicit
progress check.
readAll: now signals a TTransportError when read: answers nothing,
which is what TSocket>>read: already does for the same condition.
The tests are in a new TTransportReadAllTest suite, driven by two stub
transports: one that answers nothing at all, one that answers at most a
fixed number of bytes per call. They pin down that a stalled transport
raises instead of spinning, that it is asked exactly once rather than
repeatedly, that bytes arriving before the source dries up still end in
an error rather than a short result, and - the case the loop exists for
- that a transport answering in chunks is still assembled correctly.
Each of them runs inside valueWithin:onTimeout:, because the failure
being tested for is a hang, not an exception. Without that guard a
regression would not report a failure at all: it would sit there until
the CI job hit its own timeout.
Against the unmodified library the three stalled cases fail and the
three others pass; with the fix all six pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Client: st readInt:, readRawInt:, readString and readByte each took whatever one transport read: answered and decoded that. A read: is allowed to answer fewer bytes than it was asked for - that is ordinary socket behaviour, and TTransport has readAll: to loop until the rest arrives - but none of the four used it. The result is not an exception but a wrong answer. intFromByteArray: takes its width from the size of the buffer it was handed rather than from the number of bytes asked for, so a short read decodes to a well-formed integer that happens to be wrong, and the sign is taken from whatever byte happens to be first. Measured, with two of four bytes delivered: readI32 answers 32767 where the correct value is 2147483647. readString checks the declared size against the string limit but not how many bytes arrived, so it can answer a truncated string as if it were complete. The four now read through readAll:. That needed more than routing, contrary to what the issue suggested. readAll: accumulated into a String, and a real transport does not answer one: TSocket connects its stream 'binary', so read: answers a ByteArray, and the protocol decodes it by shifting the elements as integers. Handing a ByteArray to a String stream raises 'Improper store into indexable object'. readAll: had no callers at all, so nothing had ever exercised it against a real transport. It now builds its result with the species of what read: answered, and clamps to the requested size. The tests drive a transport that holds the whole wire content but answers at most n bytes per call, which is what a socket does. They assert the decoded VALUE rather than merely that something was raised, because the broken behaviour is a plausible wrong number: a test that only checked for a raise would pass on the unfixed code. With the four call sites reverted and the reworked readAll: kept, 9 of the 10 cases fail; with them routed, all 10 pass. The suites together are 33 tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
readInt:,readRawInt:,readStringandreadByteeach took whatever onetransport read:answered and decoded that. Aread:is allowed to answer fewer bytes than asked for — ordinary socket behaviour — andTTransporthasreadAll:to loop until the rest arrives. None of the four used it.The harm is a wrong answer, not an exception
intFromByteArray:takes its width from the size of the buffer it was handed, not from the number of bytes asked for. So a short read decodes to a well-formed integer that happens to be wrong, and the sign comes from whatever byte is first.Measured on this branch, with two of four bytes delivered:
No error. A plausible number.
readStringis the same shape: it checks the declared size against the string limit but never how many bytes arrived, so it can answer a truncated string as if it were complete.That is why the tests assert the decoded value rather than merely that something was raised — a raise-only test passes on the unfixed code.
The issue's suggested fix was not sufficient
The ticket says "Use
readAll:in all three places. It already exists and already has the loop; nothing new has to be written." That turns out not to hold:readAll:accumulated into a String, and a real transport does not answer one.TSocket>>connectsets its streambinary, soread:answers a ByteArray, and the protocol decodes it by shifting the elements as integers. Handing a ByteArray to a String stream raises:readAll:had no callers at all —grepfinds only its own definition — so nothing had ever run it against a real transport. Routing the decoders into it as-is would have swapped a wrong answer for a crash.So
readAll:now builds its result with the species of whateverread:answered, and clamps to the requested size. A test pins that directly (testReadAllKeepsTheSpeciesTheTransportAnswered).Coverage
New
TProtocolShortReadTest, 10 cases, driven by a transport that holds the whole wire content but answers at most n bytes per call:-1delivered 3 bytes at a time)readStringassembled across chunks; a string truncated by the peer raises instead of returning shortreadDouble, which goes throughreadRawInt:twicereadByteon an exhausted transport raisesTTransportErrorrather than adoesNotUnderstand: #firstreadAll:preserves the transport's speciesTwo-state verification
With the four call sites reverted and the reworked
readAll:kept — isolating exactly the routing — 9 of 10 fail. With them routed, all 10 pass. (The tenth is the species test, which does not depend on routing.)Whole binding on this branch: 33 tests, all green.
Note on scope
readByteis not named in the issue. It reads exactly one byte, so it cannot receive a short-but-non-empty result — but on an exhausted transport(read: 1) firstis adoesNotUnderstand, not a transport error. It is one line in the same family and routing it makes the failure mode consistent; happy to drop it if you would rather keep strictly to the three.JIRA: THRIFT-6300
🤖 Generated with Claude Code