yaic 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/agents/ralph-qa.md +101 -0
  3. data/.claude/ralph/bin/dump-pid.sh +3 -0
  4. data/.claude/ralph/bin/kill-claude +6 -0
  5. data/.claude/ralph/bin/start-ralph +44 -0
  6. data/.claude/ralph/bin/stop-hook.sh +9 -0
  7. data/.claude/ralph/bin/stop-ralph +17 -0
  8. data/.claude/ralph/prompt.md +218 -0
  9. data/.claude/settings.json +26 -0
  10. data/.gitmodules +3 -0
  11. data/CLAUDE.md +65 -0
  12. data/README.md +106 -17
  13. data/Rakefile +8 -0
  14. data/devenv.nix +1 -0
  15. data/docs/agents/data-model.md +150 -0
  16. data/docs/agents/ralph/features/01-message-parsing.md.done +160 -0
  17. data/docs/agents/ralph/features/01-tcpsocket-refactor.md.done +109 -0
  18. data/docs/agents/ralph/features/02-connection-socket.md.done +138 -0
  19. data/docs/agents/ralph/features/02-simplified-client-api.md.done +306 -0
  20. data/docs/agents/ralph/features/03-registration.md.done +147 -0
  21. data/docs/agents/ralph/features/04-ping-pong.md.done +109 -0
  22. data/docs/agents/ralph/features/05-event-system.md.done +167 -0
  23. data/docs/agents/ralph/features/06-privmsg-notice.md.done +163 -0
  24. data/docs/agents/ralph/features/07-join-part.md.done +190 -0
  25. data/docs/agents/ralph/features/08-quit.md.done +118 -0
  26. data/docs/agents/ralph/features/09-nick-change.md.done +109 -0
  27. data/docs/agents/ralph/features/10-topic.md.done +145 -0
  28. data/docs/agents/ralph/features/11-kick.md.done +122 -0
  29. data/docs/agents/ralph/features/12-names.md.done +124 -0
  30. data/docs/agents/ralph/features/13-mode.md.done +174 -0
  31. data/docs/agents/ralph/features/14-who-whois.md.done +188 -0
  32. data/docs/agents/ralph/features/15-client-api.md.done +180 -0
  33. data/docs/agents/ralph/features/16-ssl-test-infrastructure.md.done +50 -0
  34. data/docs/agents/ralph/features/17-github-actions-ci.md.done +70 -0
  35. data/docs/agents/ralph/features/18-brakeman-security-scanning.md.done +67 -0
  36. data/docs/agents/ralph/features/19-fix-qa.md.done +73 -0
  37. data/docs/agents/ralph/features/20-test-optimization.md.done +70 -0
  38. data/docs/agents/ralph/features/21-test-parallelization.md.done +56 -0
  39. data/docs/agents/ralph/features/22-wait-until-pattern.md.done +90 -0
  40. data/docs/agents/ralph/features/23-ping-test-optimization.md.done +46 -0
  41. data/docs/agents/ralph/features/24-blocking-who-whois.md.done +159 -0
  42. data/docs/agents/ralph/features/25-verbose-mode.md.done +166 -0
  43. data/docs/agents/ralph/plans/test-optimization-plan.md +172 -0
  44. data/docs/agents/ralph/progress.md +731 -0
  45. data/docs/agents/todo.md +5 -0
  46. data/lib/yaic/channel.rb +22 -0
  47. data/lib/yaic/client.rb +821 -0
  48. data/lib/yaic/event.rb +35 -0
  49. data/lib/yaic/message.rb +119 -0
  50. data/lib/yaic/registration.rb +17 -0
  51. data/lib/yaic/socket.rb +120 -0
  52. data/lib/yaic/source.rb +39 -0
  53. data/lib/yaic/version.rb +1 -1
  54. data/lib/yaic/who_result.rb +17 -0
  55. data/lib/yaic/whois_result.rb +20 -0
  56. data/lib/yaic.rb +13 -1
  57. metadata +51 -1
@@ -0,0 +1,150 @@
1
+ # YAIC Data Model
2
+
3
+ This document describes the core data structures used in the YAIC IRC client library.
4
+
5
+ ## IRC Message Structure
6
+
7
+ Every IRC message follows this format:
8
+ ```
9
+ ['@' <tags> SPACE] [':' <source> SPACE] <command> <parameters> <crlf>
10
+ ```
11
+
12
+ ### Message Class
13
+
14
+ ```ruby
15
+ Yaic::Message
16
+ - tags: Hash[String, String] # Optional IRCv3 message tags
17
+ - source: Yaic::Source or nil # Origin of message (nil for client-sent)
18
+ - command: String # Command name or 3-digit numeric
19
+ - params: Array[String] # Command parameters (0-15+)
20
+ - raw: String # Original raw message (for debugging)
21
+ ```
22
+
23
+ ### Source Class
24
+
25
+ Represents the origin of a message:
26
+ ```ruby
27
+ Yaic::Source
28
+ - nick: String or nil # Nickname (nil if server)
29
+ - user: String or nil # Username (after !)
30
+ - host: String or nil # Hostname (after @)
31
+ - raw: String # Original source string
32
+ ```
33
+
34
+ Formats:
35
+ - `servername` - Message from server
36
+ - `nick!user@host` - Full user prefix
37
+ - `nick!user` - User without host
38
+ - `nick@host` - User without username
39
+ - `nick` - Just nickname
40
+
41
+ ## Connection State
42
+
43
+ ```ruby
44
+ Yaic::Connection
45
+ - state: Symbol # :disconnected, :connecting, :registering, :connected
46
+ - server: String # Server hostname
47
+ - port: Integer # Server port (default 6697)
48
+ - ssl: Boolean # Use TLS/SSL
49
+ - nickname: String # Current nickname
50
+ - username: String # Username sent in USER
51
+ - realname: String # Realname sent in USER
52
+ - password: String or nil # Server password (optional)
53
+ ```
54
+
55
+ ### Connection States
56
+
57
+ 1. `:disconnected` - Not connected to server
58
+ 2. `:connecting` - TCP/TLS handshake in progress
59
+ 3. `:registering` - Sent NICK/USER, awaiting welcome
60
+ 4. `:connected` - Registered and ready for commands
61
+
62
+ ## Channel State
63
+
64
+ ```ruby
65
+ Yaic::Channel
66
+ - name: String # Channel name (e.g., "#ruby")
67
+ - topic: String or nil # Channel topic
68
+ - topic_setter: String # Who set the topic
69
+ - topic_time: Time # When topic was set
70
+ - users: Hash[String, Set[Symbol]] # nick => set of modes (@, +, etc.)
71
+ - modes: Hash[Symbol, Object] # Channel modes
72
+ ```
73
+
74
+ ## User State
75
+
76
+ ```ruby
77
+ Yaic::User
78
+ - nick: String
79
+ - user: String or nil
80
+ - host: String or nil
81
+ - realname: String or nil
82
+ - away: Boolean
83
+ - away_message: String or nil
84
+ ```
85
+
86
+ ## Event Types
87
+
88
+ Events emitted by the client:
89
+
90
+ | Event | Payload | Description |
91
+ |-------|---------|-------------|
92
+ | `:connect` | `{server:}` | Successfully connected and registered |
93
+ | `:disconnect` | `{reason:}` | Connection closed |
94
+ | `:message` | `{source:, target:, text:}` | PRIVMSG received |
95
+ | `:notice` | `{source:, target:, text:}` | NOTICE received |
96
+ | `:join` | `{channel:, user:}` | User joined channel |
97
+ | `:part` | `{channel:, user:, reason:}` | User left channel |
98
+ | `:quit` | `{user:, reason:}` | User quit IRC |
99
+ | `:kick` | `{channel:, user:, by:, reason:}` | User kicked from channel |
100
+ | `:nick` | `{old_nick:, new_nick:}` | User changed nickname |
101
+ | `:topic` | `{channel:, topic:, setter:}` | Topic changed |
102
+ | `:mode` | `{target:, modes:, args:}` | Mode changed |
103
+ | `:raw` | `{message:}` | Any raw message (for debugging) |
104
+ | `:error` | `{numeric:, message:}` | Error from server |
105
+
106
+ ## Message Length Limits
107
+
108
+ - Base message: 512 bytes (including CRLF)
109
+ - With IRCv3 tags: 512 + 8191 = 8703 bytes max
110
+ - Usable content: 510 bytes (excluding CRLF)
111
+
112
+ ## Character Encoding
113
+
114
+ - Primary: UTF-8
115
+ - Fallback: Latin-1 (ISO-8859-1)
116
+ - Invalid bytes: Replace with replacement character
117
+
118
+ ## Nickname Restrictions
119
+
120
+ Must NOT contain:
121
+ - SPACE (0x20)
122
+ - Comma (0x2C)
123
+ - Asterisk (0x2A)
124
+ - Question mark (0x3F)
125
+ - Exclamation mark (0x21)
126
+ - At sign (0x40)
127
+
128
+ Must NOT start with:
129
+ - Dollar (0x24)
130
+ - Colon (0x3A)
131
+ - Channel prefix (#, &)
132
+
133
+ ## Channel Name Restrictions
134
+
135
+ Must start with:
136
+ - `#` (regular channel)
137
+ - `&` (local channel)
138
+
139
+ Must NOT contain:
140
+ - SPACE (0x20)
141
+ - BELL (0x07)
142
+ - Comma (0x2C)
143
+
144
+ ## Numeric Reply Categories
145
+
146
+ | Range | Category |
147
+ |-------|----------|
148
+ | 001-099 | Connection/welcome |
149
+ | 200-399 | Command responses |
150
+ | 400-599 | Error responses |
@@ -0,0 +1,160 @@
1
+ # Message Parsing
2
+
3
+ ## Description
4
+
5
+ Implement the core message parsing and serialization module that converts between raw IRC protocol bytes and structured Ruby objects. This is the foundation upon which all other IRC functionality is built.
6
+
7
+ ## Behavior
8
+
9
+ ### Parsing Incoming Messages
10
+
11
+ Parse raw IRC messages in the format:
12
+ ```
13
+ ['@' <tags> SPACE] [':' <source> SPACE] <command> <parameters> <crlf>
14
+ ```
15
+
16
+ Components are parsed in order:
17
+ 1. If starts with `@`, extract tags until first SPACE
18
+ 2. If starts with `:`, extract source until first SPACE
19
+ 3. Extract command (letters or 3-digit numeric)
20
+ 4. Extract parameters (space-separated, last may have `:` prefix for trailing)
21
+
22
+ ### Serializing Outgoing Messages
23
+
24
+ Convert `Yaic::Message` objects to wire format:
25
+ - NEVER include source (clients don't send source)
26
+ - Append `\r\n` to every message
27
+ - Prepend `:` to trailing parameter if it contains spaces, is empty, or starts with `:`
28
+
29
+ ### Tag Parsing
30
+
31
+ Tags format: `@key1=value1;key2=value2`
32
+ - Split on `;`
33
+ - Split each on `=` for key/value
34
+ - Value may be empty
35
+ - Strip leading `@`
36
+
37
+ ### Source Parsing
38
+
39
+ Parse formats:
40
+ - `servername`
41
+ - `nick!user@host`
42
+ - `nick!user`
43
+ - `nick@host`
44
+ - `nick`
45
+
46
+ ### Parameter Parsing
47
+
48
+ - Split on SPACE
49
+ - Last parameter with `:` prefix is "trailing" - can contain spaces
50
+ - Strip the `:` prefix from trailing parameter
51
+
52
+ ## Models
53
+
54
+ See `docs/agents/data-model.md` for:
55
+ - `Yaic::Message` structure
56
+ - `Yaic::Source` structure
57
+
58
+ ## Tests
59
+
60
+ ### Unit Tests - Message Parsing
61
+
62
+ **Parse simple command**
63
+ - Given: `"PING :token123\r\n"`
64
+ - When: Parse message
65
+ - Then: command = "PING", params = ["token123"]
66
+
67
+ **Parse message with source**
68
+ - Given: `":nick!user@host PRIVMSG #channel :Hello world\r\n"`
69
+ - When: Parse message
70
+ - Then: source.nick = "nick", source.user = "user", source.host = "host", command = "PRIVMSG", params = ["#channel", "Hello world"]
71
+
72
+ **Parse message with tags**
73
+ - Given: `"@id=123;time=2023-01-01 :server NOTICE * :Hello\r\n"`
74
+ - When: Parse message
75
+ - Then: tags = {"id" => "123", "time" => "2023-01-01"}, source.raw = "server"
76
+
77
+ **Parse numeric reply**
78
+ - Given: `":irc.example.com 001 mynick :Welcome\r\n"`
79
+ - When: Parse message
80
+ - Then: command = "001", params = ["mynick", "Welcome"]
81
+
82
+ **Parse message with empty trailing**
83
+ - Given: `":server CAP * LIST :\r\n"`
84
+ - When: Parse message
85
+ - Then: params = ["*", "LIST", ""]
86
+
87
+ **Parse message with colon in trailing**
88
+ - Given: `":nick PRIVMSG #chan ::-)\r\n"`
89
+ - When: Parse message
90
+ - Then: params = ["#chan", ":-)"]
91
+
92
+ **Parse message without trailing colon**
93
+ - Given: `"NICK newnick\r\n"`
94
+ - When: Parse message
95
+ - Then: command = "NICK", params = ["newnick"]
96
+
97
+ **Parse source - server only**
98
+ - Given: source string "irc.example.com"
99
+ - When: Parse source
100
+ - Then: nick = nil, host = "irc.example.com"
101
+
102
+ **Parse source - full user**
103
+ - Given: source string "dan!~d@localhost"
104
+ - When: Parse source
105
+ - Then: nick = "dan", user = "~d", host = "localhost"
106
+
107
+ **Parse source - nick only**
108
+ - Given: source string "dan"
109
+ - When: Parse source
110
+ - Then: nick = "dan", user = nil, host = nil
111
+
112
+ ### Unit Tests - Message Serialization
113
+
114
+ **Serialize simple command**
115
+ - Given: Message with command = "NICK", params = ["mynick"]
116
+ - When: Serialize
117
+ - Then: Output = "NICK mynick\r\n"
118
+
119
+ **Serialize with trailing spaces**
120
+ - Given: Message with command = "PRIVMSG", params = ["#chan", "Hello world"]
121
+ - When: Serialize
122
+ - Then: Output = "PRIVMSG #chan :Hello world\r\n"
123
+
124
+ **Serialize with empty trailing**
125
+ - Given: Message with command = "TOPIC", params = ["#chan", ""]
126
+ - When: Serialize
127
+ - Then: Output = "TOPIC #chan :\r\n"
128
+
129
+ **Never include source in client messages**
130
+ - Given: Message with source set, command = "NICK", params = ["test"]
131
+ - When: Serialize
132
+ - Then: Output = "NICK test\r\n" (no source)
133
+
134
+ ### Edge Cases
135
+
136
+ **Handle LF-only line endings from server**
137
+ - Given: `"PING :test\n"`
138
+ - When: Parse message
139
+ - Then: Successfully parses (compatibility mode)
140
+
141
+ **Ignore empty lines**
142
+ - Given: `"\r\n"`
143
+ - When: Parse message
144
+ - Then: Return nil or skip
145
+
146
+ **Handle multiple spaces between components**
147
+ - Given: `":server PRIVMSG #chan :text\r\n"` (extra spaces)
148
+ - When: Parse message
149
+ - Then: Successfully parses
150
+
151
+ ## Implementation Notes
152
+
153
+ - Use a Message class with `parse` class method and `to_s` instance method
154
+ - Source should be a separate class with parsing logic
155
+ - Consider using StringScanner for efficient parsing
156
+ - UTF-8 encoding by default, with fallback to Latin-1
157
+
158
+ ## Dependencies
159
+
160
+ None - this is the foundation feature.
@@ -0,0 +1,109 @@
1
+ # TCPSocket Refactor
2
+
3
+ ## Description
4
+
5
+ Replace the low-level `::Socket` usage in `lib/yaic/socket.rb` with Ruby's higher-level `TCPSocket` class. The current implementation uses `::Socket.new` with manual address resolution and nonblocking connect patterns. `TCPSocket` provides a simpler, more readable interface while still supporting the same functionality.
6
+
7
+ ## Behavior
8
+
9
+ ### Current Implementation
10
+
11
+ The current socket uses:
12
+ - `::Socket.new(afamily, SOCK_STREAM)` - low-level socket creation
13
+ - `Addrinfo.getaddrinfo` - manual DNS resolution with IPv4 preference
14
+ - `connect_nonblock` with `IO.select` timeout handling
15
+ - `setsockopt` for TCP keepalive
16
+
17
+ ### Target Implementation
18
+
19
+ Replace with `TCPSocket` which:
20
+ - Handles address resolution internally
21
+ - Provides cleaner connection semantics with built-in timeout support
22
+ - Maintains SSL wrapping compatibility
23
+
24
+ ### Changes Required
25
+
26
+ 1. **Remove `resolve_address` method** - `TCPSocket.new` handles DNS resolution
27
+ 2. **Replace `::Socket.new` with `TCPSocket.new`** in the `connect` method
28
+ 3. **Use `connect_timeout` parameter** - `TCPSocket.new(host, port, connect_timeout: timeout)` (Ruby 3.0+)
29
+ 4. **Remove nonblocking connect logic** - no more `connect_nonblock`, `IO.select`, or `IO::WaitWritable` handling
30
+ 5. **Preserve keepalive** - Use `setsockopt` on the returned TCPSocket
31
+ 6. **SSL wrapping remains unchanged** - `wrap_ssl` works with any socket-like object
32
+
33
+ ### Connection Flow (After)
34
+
35
+ ```ruby
36
+ def connect
37
+ tcp_socket = TCPSocket.new(@host, @port, connect_timeout: @connect_timeout)
38
+ tcp_socket.setsockopt(Socket::SOL_SOCKET, Socket::SO_KEEPALIVE, true)
39
+
40
+ @socket = @ssl ? wrap_ssl(tcp_socket) : tcp_socket
41
+ @state = :connecting
42
+ end
43
+ ```
44
+
45
+ ### Error Handling
46
+
47
+ - `Errno::ETIMEDOUT` - connection timeout (behavior unchanged)
48
+ - `Errno::ECONNREFUSED` - server not listening (behavior unchanged)
49
+ - `SocketError` - DNS resolution failure (new, replaces custom handling)
50
+
51
+ ## Models
52
+
53
+ No database models involved. This is a refactor of the `Yaic::Socket` class.
54
+
55
+ ## Tests
56
+
57
+ ### Unit Tests
58
+
59
+ The existing unit tests in `test/socket_test.rb` test private buffer methods via `send()`. These do not need modification as they don't test connection logic.
60
+
61
+ ### Integration Tests
62
+
63
+ Update `test/integration/socket_test.rb` to verify the refactored implementation:
64
+
65
+ **Connection to running server**
66
+ - Given: IRC server running on localhost:6667
67
+ - When: `Socket.new("localhost", 6667).connect`
68
+ - Then: Socket state is `:connecting`, socket is usable
69
+
70
+ **Connection with SSL**
71
+ - Given: IRC server running with SSL on localhost:6697
72
+ - When: `Socket.new("localhost", 6697, ssl: true).connect`
73
+ - Then: Socket state is `:connecting`, SSL handshake completed
74
+
75
+ **Connection timeout**
76
+ - Given: Non-routable IP like 10.255.255.1
77
+ - When: `Socket.new("10.255.255.1", 6667, connect_timeout: 1).connect`
78
+ - Then: Raises `Errno::ETIMEDOUT` within ~1 second
79
+
80
+ **Connection refused**
81
+ - Given: No server running on localhost:59999
82
+ - When: `Socket.new("localhost", 59999).connect`
83
+ - Then: Raises `Errno::ECONNREFUSED`
84
+
85
+ **DNS resolution failure**
86
+ - Given: Non-existent hostname
87
+ - When: `Socket.new("this.host.does.not.exist.invalid", 6667).connect`
88
+ - Then: Raises `SocketError` with DNS-related message
89
+
90
+ **Keepalive option set**
91
+ - Given: Server running
92
+ - When: Connect and inspect socket options
93
+ - Then: `SO_KEEPALIVE` option is enabled on the underlying socket
94
+
95
+ **Read/write still work after refactor**
96
+ - Given: Connected socket
97
+ - When: Write a message and read response
98
+ - Then: Nonblocking I/O behavior unchanged
99
+
100
+ ## Implementation Notes
101
+
102
+ - Ruby 3.0+ only - uses `connect_timeout` parameter
103
+ - The `TCPSocket` class is in the `socket` library, already required
104
+ - `TCPSocket` is a subclass of `IPSocket` which is a subclass of `BasicSocket`, so all socket methods remain available
105
+ - IPv4 preference logic in `resolve_address` will be lost - `TCPSocket` uses system resolver order. This is acceptable for modern dual-stack systems.
106
+
107
+ ## Dependencies
108
+
109
+ None - this is a standalone refactoring task.
@@ -0,0 +1,138 @@
1
+ # Connection Socket
2
+
3
+ ## Description
4
+
5
+ Implement the low-level TCP/SSL socket connection handling. This provides the transport layer for sending and receiving raw IRC messages.
6
+
7
+ ## Behavior
8
+
9
+ ### Connecting
10
+
11
+ 1. Create TCP socket to server:port
12
+ 2. If SSL enabled, wrap in OpenSSL::SSL::SSLSocket
13
+ 3. Set socket to non-blocking mode for read operations
14
+ 4. Transition state to `:connecting` then `:registering`
15
+
16
+ ### Reading
17
+
18
+ - Read from socket into internal buffer
19
+ - Extract complete messages (ending in `\r\n`)
20
+ - Handle partial messages (keep in buffer until complete)
21
+ - Accept `\n` alone as line ending for compatibility
22
+
23
+ ### Writing
24
+
25
+ - Queue outgoing messages
26
+ - Append `\r\n` if not present
27
+ - Write to socket
28
+ - Handle write blocking (rare but possible)
29
+
30
+ ### Disconnecting
31
+
32
+ - Close socket gracefully
33
+ - Clear buffers
34
+ - Transition state to `:disconnected`
35
+
36
+ ### Error Handling
37
+
38
+ - Handle connection refused
39
+ - Handle connection timeout
40
+ - Handle SSL handshake failures
41
+ - Handle socket read/write errors
42
+
43
+ ## Models
44
+
45
+ ```ruby
46
+ Yaic::Socket
47
+ - socket: TCPSocket or SSLSocket
48
+ - read_buffer: String
49
+ - write_queue: Array[String]
50
+ - state: Symbol
51
+ ```
52
+
53
+ ## Tests
54
+
55
+ ### Integration Tests - Plain TCP
56
+
57
+ **Connect to server**
58
+ - Given: inspircd running on localhost:6667 (no SSL)
59
+ - When: Connect with ssl: false
60
+ - Then: Socket is connected, state is :connecting
61
+
62
+ **Read complete message**
63
+ - Given: Connected socket, server sends "PING :test\r\n"
64
+ - When: Read from socket
65
+ - Then: Returns "PING :test\r\n"
66
+
67
+ **Read partial then complete**
68
+ - Given: Connected socket
69
+ - When: Server sends "PING :" then ":test\r\n" in separate packets
70
+ - Then: First read returns nil, second read returns complete message
71
+
72
+ **Write message**
73
+ - Given: Connected socket
74
+ - When: Write "PONG :test"
75
+ - Then: Server receives "PONG :test\r\n"
76
+
77
+ **Disconnect**
78
+ - Given: Connected socket
79
+ - When: Disconnect
80
+ - Then: Socket is closed, state is :disconnected
81
+
82
+ ### Integration Tests - SSL
83
+
84
+ **Connect with SSL**
85
+ - Given: inspircd running on localhost:6697 (SSL)
86
+ - When: Connect with ssl: true
87
+ - Then: Socket is connected via TLS
88
+
89
+ **SSL handshake failure**
90
+ - Given: Server with invalid/self-signed cert
91
+ - When: Connect with ssl: true, verify_mode: OpenSSL::SSL::VERIFY_PEER
92
+ - Then: Raises SSLError or similar
93
+
94
+ ### Unit Tests - Buffer Handling
95
+
96
+ **Buffer accumulates partial messages**
97
+ - Given: Empty buffer
98
+ - When: Receive "PING" then " :test" then "\r\n"
99
+ - Then: After third receive, extract "PING :test\r\n"
100
+
101
+ **Multiple messages in one read**
102
+ - Given: Empty buffer
103
+ - When: Receive "MSG1\r\nMSG2\r\n"
104
+ - Then: Extract returns ["MSG1\r\n", "MSG2\r\n"]
105
+
106
+ **Handle LF-only endings**
107
+ - Given: Empty buffer
108
+ - When: Receive "PING :test\n"
109
+ - Then: Extract returns "PING :test\n"
110
+
111
+ ### Error Handling Tests
112
+
113
+ **Connection refused**
114
+ - Given: No server on target port
115
+ - When: Attempt connect
116
+ - Then: Raises connection error
117
+
118
+ **Connection timeout**
119
+ - Given: Server that doesn't respond
120
+ - When: Connect with timeout
121
+ - Then: Raises timeout error after specified time
122
+
123
+ **Read on closed socket**
124
+ - Given: Socket that was closed by server
125
+ - When: Attempt read
126
+ - Then: Returns nil or raises appropriate error
127
+
128
+ ## Implementation Notes
129
+
130
+ - Use Ruby's `TCPSocket` and `OpenSSL::SSL::SSLSocket`
131
+ - Set `sync = true` on SSL socket for unbuffered writes
132
+ - Consider using `IO.select` for non-blocking reads
133
+ - Buffer should be a binary string (encoding: ASCII-8BIT)
134
+ - Implement reconnection logic in higher layer, not here
135
+
136
+ ## Dependencies
137
+
138
+ - Requires `01-message-parsing.md` for message framing knowledge