Module: Ticketing::Protocol

Defined in:
lib/ticketing/protocol.rb

Overview

Wire encoding and decoding for the binary ticket-lock protocol. The byte layout matches every other client exactly.

Defined Under Namespace

Modules: Frame, Incoming

Constant Summary collapse

OP_ACQUIRE =
"A".ord
OP_RELEASE =
"R".ord
NL =
"\n".ord
MAX_KEY_LEN =
128
MAX_LINE_LEN =
192

Class Method Summary collapse

Class Method Details

.acquire_frame(key, wait, lease) ⇒ Object

Builds an ACQUIRE frame: 'A' + u16 wait + u16 lease (big-endian, whole seconds) + UTF-8 key + '\n'. Sub-second durations round up; lease is at least one second.



63
64
65
66
67
68
69
70
71
72
73
74
75
76
# File 'lib/ticketing/protocol.rb', line 63

def acquire_frame(key, wait, lease)
  kb = utf8_bytes(key)
  w = clamp_u16(ceil_secs(wait))
  l = clamp_u16([ceil_secs(lease), 1].max)
  out = String.new(encoding: Encoding::BINARY)
  out << OP_ACQUIRE
  out << ((w >> 8) & 0xFF)
  out << (w & 0xFF)
  out << ((l >> 8) & 0xFF)
  out << (l & 0xFF)
  out << kb
  out << NL
  out
end

.be_long(bytes, off) ⇒ Object



158
159
160
161
162
# File 'lib/ticketing/protocol.rb', line 158

def be_long(bytes, off)
  v = 0
  8.times { |i| v = (v << 8) | bytes.getbyte(off + i) }
  v
end

.ceil_secs(duration) ⇒ Object



164
165
166
167
168
# File 'lib/ticketing/protocol.rb', line 164

def ceil_secs(duration)
  return 0 if duration.nil? || duration <= 0

  duration.ceil
end

.clamp_u16(value) ⇒ Object



170
171
172
173
174
175
# File 'lib/ticketing/protocol.rb', line 170

def clamp_u16(value)
  return 0 if value < 0
  return 0xFFFF if value > 0xFFFF

  value
end

.parse_reply(body) ⇒ Object

Decodes a reply body (op + fixed + tail, newline already stripped) into an Incoming, or nil if the op is unknown or the body is malformed.



90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
# File 'lib/ticketing/protocol.rb', line 90

def parse_reply(body)
  return nil if body.bytesize.zero?

  op = body.getbyte(0)
  tail = body.byteslice(1, body.bytesize - 1) || "".b
  case op
  when "A".ord
    return nil if tail.bytesize < 8

    token = be_long(tail, 0)
    Incoming::Acquired.new(token, tail.byteslice(8, tail.bytesize - 8).to_s.force_encoding(Encoding::UTF_8))
  when "T".ord then Incoming::TimedOut.new(tail.dup.force_encoding(Encoding::UTF_8))
  when "R".ord then Incoming::Released.new(tail.dup.force_encoding(Encoding::UTF_8))
  when "N".ord then Incoming::NotFound.new(tail.dup.force_encoding(Encoding::UTF_8))
  when "M".ord then Incoming::Moved.new(tail.dup.force_encoding(Encoding::UTF_8))
  when "E".ord then Incoming::Error.new(tail.dup.force_encoding(Encoding::UTF_8))
  end
end

.read_frame(input) ⇒ Object

Reads one framed message from input: a one-byte op, then the fixed bytes for that op (via fixed_for), then a tail terminated by '\n'. Returns the bytes as +op + fixed + tail+ with the newline stripped. A lone '\n' is an empty heartbeat frame. Over-long lines are drained and reported as Ticketing::Protocol::Frame::TOO_LONG without closing the connection.



114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
# File 'lib/ticketing/protocol.rb', line 114

def read_frame(input)
  first = input.getbyte
  return Frame::EOF if first.nil?
  return Frame.data("".b) if first == NL

  op = first
  fixed = yield(op)
  head = String.new(encoding: Encoding::BINARY)
  head << op
  got = 0
  while got < fixed
    chunk = input.read(fixed - got)
    return Frame::EOF if chunk.nil? || chunk.bytesize.zero?

    head << chunk
    got += chunk.bytesize
  end

  tail = String.new(encoding: Encoding::BINARY)
  too_long = head.bytesize > MAX_LINE_LEN
  loop do
    b = input.getbyte
    return Frame::EOF if b.nil?
    break if b == NL

    unless too_long
      tail << b
      too_long = true if head.bytesize + tail.bytesize > MAX_LINE_LEN
    end
  end
  return Frame::TOO_LONG if too_long

  body = String.new(encoding: Encoding::BINARY)
  body << head
  body << tail
  Frame.data(body)
end

.release_frame(key) ⇒ Object

Builds a RELEASE frame: 'R' + UTF-8 key + '\n'.



79
80
81
82
83
84
85
86
# File 'lib/ticketing/protocol.rb', line 79

def release_frame(key)
  kb = utf8_bytes(key)
  out = String.new(encoding: Encoding::BINARY)
  out << OP_RELEASE
  out << kb
  out << NL
  out
end

.reply_fixed(op) ⇒ Object

Number of fixed bytes that follow the op byte of a reply.



41
42
43
# File 'lib/ticketing/protocol.rb', line 41

def reply_fixed(op)
  op == OP_ACQUIRE ? 8 : 0
end

.utf8_bytes(str) ⇒ Object



152
153
154
155
156
# File 'lib/ticketing/protocol.rb', line 152

def utf8_bytes(str)
  str.to_s.encode(Encoding::UTF_8).b
rescue Encoding::UndefinedConversionError, Encoding::InvalidByteSequenceError
  str.to_s.b
end

.validate_key(key) ⇒ Object

Rejects a key that is empty, over MAX_KEY_LEN bytes, or contains a space, carriage return, or newline.



47
48
49
50
51
52
53
54
55
56
57
58
# File 'lib/ticketing/protocol.rb', line 47

def validate_key(key)
  bytes = utf8_bytes(key)
  if bytes.bytesize.zero? || bytes.bytesize > MAX_KEY_LEN
    raise InvalidInput, "key must be 1..=128 bytes"
  end

  bytes.each_byte do |b|
    if b == 0x20 || b == 0x0A || b == 0x0D
      raise InvalidInput, "key must not contain spaces or newlines"
    end
  end
end