What a Missing Feature Hides: Two Plain Bugs Under One Structural Gap

mere-ruby's IO was weak, and the reason looked obvious: its File has no file descriptor. I was about to add one. Measuring first showed the descriptor would fix eight rows of thirty-five — and the work I did instead uncovered two ordinary bugs that the structural explanation had been covering for. On why the order of work decides what you are able to find.

mere-rubyrubymeasurementdebuggingdeveloper-experiencetechnical-debt

mere-ruby is a Ruby-subset interpreter written in Mere, a small ML-family language I am building. Its conformance record is a checked-in table: every group of ruby/spec run against the real ruby, compared byte for byte.

core/io was one of the worst groups in that table — 45 of 81 files matching, thirty-five diverging. The reason looked settled, and it was written down in the project’s own gap list: a File here is a path, a mode and a buffer. It has no file descriptor. #fileno does not return a made-up number, it refuses and says why.

That is a real limitation, and it explains a lot. So the obvious next task was to give the interpreter real descriptors — open files properly, keep an offset, build the IO surface on top. A few days of work, structural, satisfying.

I measured first, and the measurement said something else.

What the descriptor would actually buy

The list of IO methods this interpreter does not answer is 43 names long. I split it by one question: does this need a file descriptor, or only a path and a position?

needs a descriptor   19   advise autoclose? close_on_exec? fcntl fdatasync
                          for_fd fsync ioctl pid popen read_nonblock sysopen
                          sysseek timeout to_i wait_priority wait_readable
                          wait_writable write_nonblock

needs no descriptor  24   binmode binmode? close_read close_write copy_stream
                          eof eof? lineno open pos pread printf pwrite reopen
                          rewind seek set_encoding set_encoding_by_bom stat
                          syswrite tell to_io try_convert ungetbyte ungetc

Twenty-four of the forty-three are expressible with what the interpreter already has. And the thirty-five failing files break down the same way: fifteen are a missing method, eight are the explicit “there is no descriptor” refusal. A descriptor would move about eight rows.

Against that: a descriptor changes the model. The File is a path and a buffer today, and every read and write path is written that way. Descriptors and whole-file buffering do not coexist — adding one means rewriting the other. A few days of work to move eight rows, with the other twenty-four names still unwritten at the end of it.

So I did the twenty-four first. Not because it was bigger, but because it was the part that did not require deciding anything.

The two bugs underneath

Writing the byte-at-a-time readers meant calling read(1) in a loop. The first one I wrote returned the entire file.

f = File.open("/tmp/ten-bytes.txt")
f.read(3)   # "abcdefghij"   <- every byte
f.pos       # 10
f.read(3)   # ""

File#read ignored its length argument. It read the whole file, set the position to the end, and the next read saw nothing. Any code that reads in chunks — which is most code that reads at all — saw one giant chunk followed by end-of-file.

The class method had the same shape:

File.binread(path, 3, 2)   # "abcdefghij", where ruby answers "cde"

Both had been there for a long time. Both are now fixed and checked against the reference implementation, which is the oracle this project uses for everything.

Neither has anything to do with file descriptors.

Why they were invisible

This is the part I want to keep.

A structural explanation is comfortable. “IO is weak because there is no descriptor” is true, it is specific, and it accounts for the shape of the failure. Once you have it, you stop looking — not deliberately, but because the question feels answered. The group is bad for a reason you already know.

But read ignoring its length is not that reason. It was sitting in the same group, behind the same headline, and the headline was covering for it.

And the order of work decides whether you find it. Had I built descriptors first, read(len) would have been rewritten as part of that work. The bug would have disappeared into a diff of several hundred lines — fixed, probably, but never seen. I would not have learned that the length argument had been ignored all along, and I would have attributed the improvement to the descriptors.

That is the difference between a fix and a finding. A fix changes the code. A finding changes what you believe about the code, and that belief is what you use to choose the next task.

The general shape

I now think of it this way: a missing capability is a shadow, and ordinary bugs sit in it.

The shadow is cast by anything that stops people from reaching the code:

  • a class that does not exist, so nothing behind it is exercised
  • a method that raises NotImplementedError, so its callers never run
  • a feature everyone knows is absent, so its area is never reported

While a caller cannot get in, whatever is inside is unmeasured. The moment the door opens, what was behind it gets tested for the first time — and some of it was never about the door.

I had seen this before on the same project without recognising it. Adding one missing method to the gzip writer — a constructor the suite uses — made a previously-unreachable spec run, and it immediately hit the harness’s CPU limit. The compression engine turned out to run at about 34 seconds per megabyte. That cost had always been there. Nothing had reached it, so nothing had measured it, and the record had shown a clean missing-method error where the truth was a missing method and a performance problem.

What I would tell myself

Three things, in the order they matter.

Measure the explanation, not just the symptom. “IO is weak because there is no descriptor” is a claim about how many rows a descriptor would move. That number is countable before any code is written. It was eight of thirty-five.

Prefer the work that requires no decision. Twenty-four names needed no design judgment — they were expressible with what already existed. The descriptor needed a decision about the whole File model. Doing the unblocked work first is not procrastination; it produces the measurements that the decision needs.

Treat the first thing you find after opening a door as evidence, not noise. When a newly-reachable area immediately fails in some unrelated way, that is not bad luck. That is the shadow clearing.

The descriptor decision is still open. But I will make it with a number now instead of an intuition, and with two fewer bugs hiding behind it.

← Back to Notes