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.
- checksums.yaml +4 -4
- data/.claude/agents/ralph-qa.md +101 -0
- data/.claude/ralph/bin/dump-pid.sh +3 -0
- data/.claude/ralph/bin/kill-claude +6 -0
- data/.claude/ralph/bin/start-ralph +44 -0
- data/.claude/ralph/bin/stop-hook.sh +9 -0
- data/.claude/ralph/bin/stop-ralph +17 -0
- data/.claude/ralph/prompt.md +218 -0
- data/.claude/settings.json +26 -0
- data/.gitmodules +3 -0
- data/CLAUDE.md +65 -0
- data/README.md +106 -17
- data/Rakefile +8 -0
- data/devenv.nix +1 -0
- data/docs/agents/data-model.md +150 -0
- data/docs/agents/ralph/features/01-message-parsing.md.done +160 -0
- data/docs/agents/ralph/features/01-tcpsocket-refactor.md.done +109 -0
- data/docs/agents/ralph/features/02-connection-socket.md.done +138 -0
- data/docs/agents/ralph/features/02-simplified-client-api.md.done +306 -0
- data/docs/agents/ralph/features/03-registration.md.done +147 -0
- data/docs/agents/ralph/features/04-ping-pong.md.done +109 -0
- data/docs/agents/ralph/features/05-event-system.md.done +167 -0
- data/docs/agents/ralph/features/06-privmsg-notice.md.done +163 -0
- data/docs/agents/ralph/features/07-join-part.md.done +190 -0
- data/docs/agents/ralph/features/08-quit.md.done +118 -0
- data/docs/agents/ralph/features/09-nick-change.md.done +109 -0
- data/docs/agents/ralph/features/10-topic.md.done +145 -0
- data/docs/agents/ralph/features/11-kick.md.done +122 -0
- data/docs/agents/ralph/features/12-names.md.done +124 -0
- data/docs/agents/ralph/features/13-mode.md.done +174 -0
- data/docs/agents/ralph/features/14-who-whois.md.done +188 -0
- data/docs/agents/ralph/features/15-client-api.md.done +180 -0
- data/docs/agents/ralph/features/16-ssl-test-infrastructure.md.done +50 -0
- data/docs/agents/ralph/features/17-github-actions-ci.md.done +70 -0
- data/docs/agents/ralph/features/18-brakeman-security-scanning.md.done +67 -0
- data/docs/agents/ralph/features/19-fix-qa.md.done +73 -0
- data/docs/agents/ralph/features/20-test-optimization.md.done +70 -0
- data/docs/agents/ralph/features/21-test-parallelization.md.done +56 -0
- data/docs/agents/ralph/features/22-wait-until-pattern.md.done +90 -0
- data/docs/agents/ralph/features/23-ping-test-optimization.md.done +46 -0
- data/docs/agents/ralph/features/24-blocking-who-whois.md.done +159 -0
- data/docs/agents/ralph/features/25-verbose-mode.md.done +166 -0
- data/docs/agents/ralph/plans/test-optimization-plan.md +172 -0
- data/docs/agents/ralph/progress.md +731 -0
- data/docs/agents/todo.md +5 -0
- data/lib/yaic/channel.rb +22 -0
- data/lib/yaic/client.rb +821 -0
- data/lib/yaic/event.rb +35 -0
- data/lib/yaic/message.rb +119 -0
- data/lib/yaic/registration.rb +17 -0
- data/lib/yaic/socket.rb +120 -0
- data/lib/yaic/source.rb +39 -0
- data/lib/yaic/version.rb +1 -1
- data/lib/yaic/who_result.rb +17 -0
- data/lib/yaic/whois_result.rb +20 -0
- data/lib/yaic.rb +13 -1
- 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
|