Skip to content

Add scripted-transport harness and Modbus golden-frame tests#18

Merged
jmmoser merged 10 commits into
mainfrom
claude/bug-review-evfh5y
Jun 25, 2026
Merged

Add scripted-transport harness and Modbus golden-frame tests#18
jmmoser merged 10 commits into
mainfrom
claude/bug-review-evfh5y

Conversation

@jmmoser

@jmmoser jmmoser commented Jun 25, 2026

Copy link
Copy Markdown
Owner

Adds a ScriptedTransport fake lower layer that records sent buffers and
delivers scripted responses through Layer.forward, enabling full-stack
protocol layer tests against exact wire bytes without a socket.

The Modbus tests assert byte sequences taken from the worked examples in
the MODBUS Application Protocol Specification V1.1b3, so encode and
decode are checked against the spec rather than against each other.

These tests currently fail, documenting known bugs fixed in the next
commit:

  • writeSingleCoil sends 0x00FF instead of 0xFF00 for ON
  • writeMultipleCoils emits the WriteSingle layout (no quantity/byte
    count/bit packing) and mutates the caller's array
  • register writes use writeInt16BE, rejecting unsigned values >= 0x8000
  • readHoldingRegisters(address, callback) treats the callback as the
    register count
  • the defragger only forwards the last complete frame when one chunk
    contains several

https://claude.ai/code/session_01AuAYuP6dTn8ALpkKErQuby

claude added 10 commits June 12, 2026 16:35
Adds a ScriptedTransport fake lower layer that records sent buffers and
delivers scripted responses through Layer.forward, enabling full-stack
protocol layer tests against exact wire bytes without a socket.

The Modbus tests assert byte sequences taken from the worked examples in
the MODBUS Application Protocol Specification V1.1b3, so encode and
decode are checked against the spec rather than against each other.

These tests currently fail, documenting known bugs fixed in the next
commit:
- writeSingleCoil sends 0x00FF instead of 0xFF00 for ON
- writeMultipleCoils emits the WriteSingle layout (no quantity/byte
  count/bit packing) and mutates the caller's array
- register writes use writeInt16BE, rejecting unsigned values >= 0x8000
- readHoldingRegisters(address, callback) treats the callback as the
  register count
- the defragger only forwards the last complete frame when one chunk
  contains several

https://claude.ai/code/session_01AuAYuP6dTn8ALpkKErQuby
- writeSingleCoil now encodes the spec-mandated 0xFF00 for ON; a
  conforming server rejects any other nonzero value with Illegal Data
  Value, so coils could never be switched on
- writeMultipleCoils now emits the proper function 0x0F layout
  (quantity of outputs, byte count, LSB-first bit-packed coils) via a
  dedicated PDU encoder, and no longer mutates the caller's array
- register writes use writeUInt16BE with masking, so unsigned values
  >= 0x8000 and negative two's-complement values both encode instead
  of throwing
- read requests shuffle (address, callback) arguments and default the
  count to 1, so the documented callback form no longer encodes the
  callback function as the register count
- Defragger now returns one frame per call (callable without new data
  to drain), and Layer.forwardTo loops until the buffer is exhausted;
  previously every complete frame except the last in a chunk was
  silently dropped, hanging the requests awaiting them

Verified against the unit suite (198 passing) and both integration
tests run locally against real servers: pymodbus and OpENer.

https://claude.ai/code/session_01AuAYuP6dTn8ALpkKErQuby
Drives the PCCC layer through the scripted transport and asserts the
exact DF1 wire bytes (CMD/STS/TNS/FNC, logical ASCII addressing, FLAG
byte data descriptors) for typed read/write, word range read,
diagnostic status, and echo, plus STS/EXT-STS error mapping and
transaction matching.

Three tests fail against the current code, documenting known bugs
fixed in the next commit:
- typedWrite passes the offset ref object instead of the offset number
  when encoding extended data descriptors, so float writes always
  throw ERR_INVALID_ARG_TYPE
- typedWrite for timer/counter/control/string/long files reaches the
  same broken path through an undefined type id instead of failing
  with a clear unsupported-type error

https://claude.ai/code/session_01AuAYuP6dTn8ALpkKErQuby
- EncodeDataDescriptor passed the offset ref object instead of the
  offset number in the extended-type-id branch, so every typed write
  whose type id needs an extension byte (notably floats, id 0x08)
  threw ERR_INVALID_ARG_TYPE instead of writing
- TypedWriteRequest now rejects timer/counter/control/string/long
  addresses with a clear unsupported-type error; previously the
  undefined type id flowed into the descriptor encoder and produced
  the same cryptic TypeError
- PCCC internal replies no longer fall through to forward() when the
  context callback was already consumed

https://claude.ai/code/session_01AuAYuP6dTn8ALpkKErQuby
Extends the scripted transport with request objects, contextual
delivery, and a fallback responder, and adds a LogixResponder harness
that answers ForwardOpen/ForwardClose and routes connected messages to
scripted Message Router responses, so the full
Logix5000 -> CIPConnectionLayer stack runs against exact wire bytes.

Covers the ForwardOpen/ForwardClose handshake, Read Tag (atomic DINT/
REAL, arrays, fragmented transfers, error statuses), Write Tag type
lookup, Read Modify Write, request timeouts, and the connection
keep-alive lifecycle.

Most of these tests fail against the current code, documenting known
bugs fixed in the next commit:
- readTag throws for every non-empty response (stale EPath.Decode call)
- fragmented reads conflate the request offset with the reply parse
  offset and fail beyond the first fragment
- tag cache keys collapse to one key per scope, so writeTag encodes
  with the wrong tag's data type
- object-mapped CIP statuses without extended status crash response
  decoding
- request timeouts are discarded, so lost responses hang forever
- the connected keep-alive interval leaks after destroy
- mask range validation compares the masks array instead of its values

https://claude.ai/code/session_01AuAYuP6dTn8ALpkKErQuby
- parseReadTag called Logix5000DecodeDataType with a numeric offset and
  a callback, both stale from the offset-ref refactor of EPath.Decode;
  the data type was never decoded so every readTag with a non-empty
  response threw. It now decodes through an offset ref and uses the
  returned segment's value.
- readTagFragmented reused one offset ref as both the fragmented-read
  request offset and the reply parse cursor, so any tag needing more
  than one fragment failed or returned corrupted data; the request
  offset is now tracked separately and accumulates per fragment.
- scopedGenerator returned an arrow function spreading the outer
  function's arguments, collapsing every tag cache key in a scope to a
  single entry; symbol instance IDs and cached types could belong to a
  different tag, making writeTag encode with the wrong data type. It
  now builds keys from its own parameters.
- statusHandler indexed object-mapped status descriptions with
  extended.readUInt16LE(0) whenever extended.length >= 0, crashing on
  empty extended status buffers; it now requires two bytes and falls
  back to the generic description.
- parseReadTagMemberStructure returned an Error object as if it were
  decoded data; it now throws.
- readModifyWriteTag validated the masks array instead of its elements,
  so out-of-range values produced a cryptic buffer RangeError.
- Request timeouts are now plumbed through Logix5000 send ->
  CIPInternalLayer.sendRequest -> contextCallback instead of being
  discarded, so lost responses reject instead of hanging forever (this
  also makes the listTags retry/backoff logic reachable).
- CIPConnectionLayer.handleDestroy now stops the connected keep-alive
  resend interval, which previously kept firing into the destroyed
  stack.

https://claude.ai/code/session_01AuAYuP6dTn8ALpkKErQuby
Covers RegisterSession golden frames, EIP header status round-trips,
ListServices CPF item decoding, session registration through the
scripted transport, and listIdentity for single and multiple hosts.

Three tests fail against the current code, documenting known bugs
fixed in the next commit:
- EIPPacket.Encode reads .code off the numeric status argument, so any
  nonzero status is silently encoded as 0
- the ListServices name scan runs one byte past the 16-byte field
- listIdentity with multiple hosts drops its reply callback after the
  first response and only ever returns one device

https://claude.ai/code/session_01AuAYuP6dTn8ALpkKErQuby
- EIPPacket.Encode read .code off its numeric status argument, so any
  nonzero status was silently encoded as 0 (every caller passes a
  number; toBuffer passes this.status.code)
- the ListServices name scan checked one byte past the 16-byte name
  field, letting a stray 17th byte leak into the decoded name
- listIdentity's reply handler now returns true for host-specified
  scans so it stays registered after the first reply; previously only
  the first device discovered was ever returned

https://claude.ai/code/session_01AuAYuP6dTn8ALpkKErQuby
The integration test exercises ForwardOpen, sequenced connected
GetAttributeSingle requests, and ForwardClose against OpENer — the
same machinery the Logix5000 unit tests emulate with LogixResponder,
now validated against a real third-party CIP stack.

Running it exposed a TCP layer bug, captured by the new unit test
(against a real net.Server): a deferred sendNextMessage wakeup after
close() reopens the connection, because the disconnected branch
reconnects unconditionally. This leaks a fresh socket after every
close and keeps the process alive. Fixed in the next commit.

https://claude.ai/code/session_01AuAYuP6dTn8ALpkKErQuby
TCPLayer.sendNextMessage reconnected unconditionally whenever the
state was disconnected, so any deferred wakeup arriving after close()
(e.g. from the final write's callback) silently opened a fresh socket
that nothing would ever close. It now reconnects only when the request
queue is non-empty.

Adds the OpENer connected-messaging integration test to CI.

https://claude.ai/code/session_01AuAYuP6dTn8ALpkKErQuby
@jmmoser jmmoser merged commit 2c596e3 into main Jun 25, 2026
1 check passed
@jmmoser jmmoser deleted the claude/bug-review-evfh5y branch June 25, 2026 14:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants