winipc 0.1.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.
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Winipc
4
+ VERSION = "0.1.0"
5
+ end
data/lib/winipc.rb ADDED
@@ -0,0 +1,338 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "winipc/version"
4
+ require "winipc/winipc" # native extension: classes + errors + K_* constants
5
+
6
+ # winipc — Windows local IPC for Ruby: named pipes, shared memory, and named
7
+ # synchronization objects, with a safe-by-default API.
8
+ #
9
+ # # named pipe, server side
10
+ # Winipc::Pipe.listen("myapp/control") do |server|
11
+ # conn = server.accept
12
+ # puts conn.read(1024)
13
+ # conn.write("ack")
14
+ # conn.close
15
+ # end
16
+ #
17
+ # # client side (another process)
18
+ # Winipc::Pipe.connect("myapp/control") do |c|
19
+ # c.write("hello")
20
+ # c.read(1024)
21
+ # end
22
+ module Winipc
23
+ # ---- Win32 flag values (verified) --------------------------------------
24
+ PIPE_ACCESS_INBOUND = 0x00000001
25
+ PIPE_ACCESS_OUTBOUND = 0x00000002
26
+ PIPE_ACCESS_DUPLEX = 0x00000003
27
+ FILE_FLAG_OVERLAPPED = 0x40000000
28
+ PIPE_TYPE_MESSAGE = 0x00000004
29
+ PIPE_READMODE_MESSAGE = 0x00000002
30
+ PIPE_WAIT = 0x00000000
31
+ PIPE_REJECT_REMOTE_CLIENTS = 0x00000008
32
+ PIPE_UNLIMITED_INSTANCES = 255
33
+ GENERIC_READ = 0x80000000
34
+ GENERIC_WRITE = 0x40000000
35
+
36
+ # A Windows API failure carries the originating error code (GetLastError /
37
+ # the failing return), set on the exception in C.
38
+ class OSError
39
+ def code
40
+ @code
41
+ end
42
+ end
43
+
44
+ module_function
45
+
46
+ # Run a blocking native call cooperatively. Under a Fiber scheduler (e.g.
47
+ # winloop) the call is offloaded to a worker Thread so the calling fiber
48
+ # parks (Thread#value routes through the scheduler) and the event loop keeps
49
+ # serving other fibers; with no scheduler it runs inline (the C call already
50
+ # releases the GVL). On fiber unwind the worker is killed+joined so it can't
51
+ # leak or consume data destined for a later op.
52
+ #
53
+ # Caveat: if the fiber is unwound (e.g. Timeout) in the instant after the
54
+ # worker acquired a resource but before the value was delivered, that
55
+ # acquisition is lost — there is no generic way to hand it back (the same
56
+ # inherent limitation as a cancelled read that already pulled bytes).
57
+ def run_blocking
58
+ sched = Fiber.scheduler
59
+ return yield unless sched
60
+
61
+ worker = Thread.new do
62
+ Thread.current.report_on_exception = false
63
+ yield
64
+ end
65
+ begin
66
+ worker.value
67
+ ensure
68
+ if worker.alive?
69
+ worker.kill
70
+ worker.join
71
+ end
72
+ end
73
+ end
74
+
75
+ # Normalize a bare pipe name to the \\.\pipe\ namespace (pass a full
76
+ # \\server\pipe\name through unchanged).
77
+ def pipe_path(name)
78
+ s = name.to_s
79
+ s.start_with?("\\\\") ? s : "\\\\.\\pipe\\#{s}"
80
+ end
81
+
82
+ # Map a bare object name into the Local\ (default) or Global\ namespace.
83
+ def obj_path(name, scope)
84
+ prefix = case scope
85
+ when :local then "Local\\"
86
+ when :global then "Global\\"
87
+ else raise ArgumentError, "scope must be :local or :global, got #{scope.inspect}"
88
+ end
89
+ "#{prefix}#{name}"
90
+ end
91
+
92
+ def server_access(direction)
93
+ case direction
94
+ when :duplex then PIPE_ACCESS_DUPLEX
95
+ when :inbound then PIPE_ACCESS_INBOUND
96
+ when :outbound then PIPE_ACCESS_OUTBOUND
97
+ else raise ArgumentError, "direction must be :duplex/:inbound/:outbound"
98
+ end
99
+ end
100
+ private_class_method :server_access
101
+
102
+ def client_access(direction)
103
+ case direction
104
+ when :duplex then GENERIC_READ | GENERIC_WRITE
105
+ when :read then GENERIC_READ
106
+ when :write then GENERIC_WRITE
107
+ else raise ArgumentError, "direction must be :duplex/:read/:write"
108
+ end
109
+ end
110
+ private_class_method :client_access
111
+
112
+ def ms_for(timeout)
113
+ return -1 if timeout.nil? # INFINITE (block until signaled / connected)
114
+
115
+ t = Float(timeout)
116
+ raise ArgumentError, "timeout must be non-negative, got #{timeout.inspect}" if t.negative?
117
+
118
+ ms = (t * 1000).round
119
+ # Never collapse a tiny-but-positive wait into a non-blocking poll.
120
+ ms.zero? && t.positive? ? 1 : ms
121
+ end
122
+
123
+ class Pipe
124
+ # Create a named-pipe server. Yields a Listener (auto-closed) if a block is
125
+ # given, else returns it.
126
+ def self.listen(name, mode: :byte, direction: :duplex, max_instances: :unlimited,
127
+ in_buffer: 65_536, out_buffer: 65_536, reject_remote: true, access: :owner)
128
+ open_mode = Winipc.send(:server_access, direction) | FILE_FLAG_OVERLAPPED
129
+ pipe_mode = PIPE_WAIT
130
+ pipe_mode |= PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE if mode == :message
131
+ pipe_mode |= PIPE_REJECT_REMOTE_CLIENTS if reject_remote
132
+ max = max_instances == :unlimited ? PIPE_UNLIMITED_INSTANCES : Integer(max_instances)
133
+ raise ArgumentError, "max_instances must be 1..255" unless (1..255).cover?(max)
134
+
135
+ listener = _listen(Winipc.pipe_path(name), open_mode, pipe_mode, max,
136
+ Integer(in_buffer), Integer(out_buffer), access)
137
+ return listener unless block_given?
138
+
139
+ begin
140
+ yield listener
141
+ ensure
142
+ listener.close
143
+ end
144
+ end
145
+
146
+ # Connect to a named-pipe server. Yields a Conn (auto-closed) if a block is
147
+ # given, else returns it.
148
+ # +timeout+: nil fails fast if the server isn't connectable now; a number of
149
+ # seconds retries (server busy or not yet up) until it elapses.
150
+ def self.connect(name, mode: :byte, direction: :duplex, timeout: nil)
151
+ access = Winipc.send(:client_access, direction)
152
+ ms =
153
+ if timeout.nil?
154
+ 0 # fail fast if the server isn't connectable now
155
+ else
156
+ t = Float(timeout)
157
+ raise ArgumentError, "timeout must be non-negative, got #{timeout.inspect}" if t.negative?
158
+
159
+ msr = (t * 1000).round
160
+ msr.zero? && t.positive? ? 1 : msr # a tiny positive timeout still waits, not fail-fast
161
+ end
162
+ conn = Winipc.run_blocking do
163
+ _connect(Winipc.pipe_path(name), access, mode == :message, ms)
164
+ end
165
+ return conn unless block_given?
166
+
167
+ begin
168
+ yield conn
169
+ ensure
170
+ conn.close
171
+ end
172
+ end
173
+
174
+ class Listener
175
+ # Accept the next client. Blocks (cooperatively under a scheduler). With a
176
+ # timeout, returns nil if none connects in time.
177
+ def accept(timeout: nil)
178
+ Winipc.run_blocking { _accept(Winipc.ms_for(timeout)) }
179
+ end
180
+
181
+ # Accept clients in a loop, yielding (and closing) each connection.
182
+ def serve
183
+ loop do
184
+ conn = accept
185
+ break if conn.nil?
186
+
187
+ begin
188
+ yield conn
189
+ ensure
190
+ conn.close
191
+ end
192
+ end
193
+ end
194
+ end
195
+
196
+ class Conn
197
+ # Read up to +maxlen+ bytes (byte mode). Returns a binary String, or nil
198
+ # at clean EOF (peer closed).
199
+ def read(maxlen)
200
+ Winipc.run_blocking { _read(maxlen) }
201
+ end
202
+
203
+ # Read one whole message (message mode), reassembling across the buffer.
204
+ # Returns a binary String or nil at EOF.
205
+ def read_message(maxlen = 65_536)
206
+ raise ModeError, "winipc: pipe is not in message mode" unless message?
207
+
208
+ Winipc.run_blocking { _read_message(maxlen) }
209
+ end
210
+
211
+ # Write +bytes+; returns the number of bytes written.
212
+ def write(bytes)
213
+ Winipc.run_blocking { _write(bytes) }
214
+ end
215
+ alias write_message write # in message mode each write is one message
216
+
217
+ def <<(bytes)
218
+ write(bytes)
219
+ self
220
+ end
221
+ end
222
+ end
223
+
224
+ class SharedMemory
225
+ # Create a named, pagefile-backed shared-memory region of +size+ bytes.
226
+ def self.create(name, size, access: :readwrite, scope: :local, security: :owner)
227
+ _create(Winipc.obj_path(name, scope), Integer(size), access != :readonly, security)
228
+ end
229
+
230
+ # Open an existing named region.
231
+ def self.open(name, access: :readwrite, scope: :local)
232
+ _open(Winipc.obj_path(name, scope), access != :readonly)
233
+ end
234
+
235
+ # Flush a range (default: whole region) to backing storage.
236
+ def flush(offset = 0, len = nil)
237
+ _flush(offset, len)
238
+ end
239
+ end
240
+
241
+ class Mutex
242
+ def self.create(name, scope: :local, security: :owner)
243
+ _create(K_MUTEX, Winipc.obj_path(name, scope), nil, nil, security)
244
+ end
245
+
246
+ def self.open(name, scope: :local)
247
+ _open(K_MUTEX, Winipc.obj_path(name, scope))
248
+ end
249
+
250
+ # NOTE: a Windows mutex is owned by the acquiring THREAD, so Mutex waits are
251
+ # NOT offloaded to a worker (that would acquire on the wrong thread). They
252
+ # release the GVL but, under a fiber scheduler, block the loop for the wait.
253
+ # Use Event/Semaphore for fiber-cooperative coordination.
254
+ def lock(timeout: nil)
255
+ _wait(Winipc.ms_for(timeout)) != :timeout # :ok or :abandoned both acquire
256
+ end
257
+
258
+ def try_lock
259
+ lock(timeout: 0)
260
+ end
261
+
262
+ def unlock
263
+ _unlock
264
+ self
265
+ end
266
+
267
+ def synchronize(timeout: nil)
268
+ result = _wait(Winipc.ms_for(timeout))
269
+ raise TimeoutError, "winipc: mutex not acquired within timeout" if result == :timeout
270
+
271
+ begin
272
+ if result == :abandoned
273
+ raise Abandoned, "winipc: mutex was abandoned by a dead owner; shared state may be inconsistent"
274
+ end
275
+
276
+ yield
277
+ ensure
278
+ _unlock
279
+ end
280
+ end
281
+ end
282
+
283
+ class Event
284
+ def self.create(name, manual_reset: false, initial: false, scope: :local, security: :owner)
285
+ _create(K_EVENT, Winipc.obj_path(name, scope), manual_reset, initial, security)
286
+ end
287
+
288
+ def self.open(name, scope: :local)
289
+ _open(K_EVENT, Winipc.obj_path(name, scope))
290
+ end
291
+
292
+ # Wait for the event to be signaled. Returns true, or false on timeout.
293
+ def wait(timeout: nil)
294
+ Winipc.run_blocking { _wait(Winipc.ms_for(timeout)) } == :ok
295
+ end
296
+ # #signal and #reset are defined in C.
297
+ end
298
+
299
+ class Semaphore
300
+ def self.create(name, initial:, maximum:, scope: :local, security: :owner)
301
+ unless maximum.is_a?(Integer) && maximum > 0 && initial.is_a?(Integer) && initial >= 0 && initial <= maximum
302
+ raise ArgumentError, "require 0 <= initial <= maximum and maximum > 0"
303
+ end
304
+
305
+ _create(K_SEM, Winipc.obj_path(name, scope), initial, maximum, security)
306
+ end
307
+
308
+ def self.open(name, scope: :local)
309
+ _open(K_SEM, Winipc.obj_path(name, scope))
310
+ end
311
+
312
+ # Acquire one unit. Returns true, or false on timeout.
313
+ def acquire(timeout: nil)
314
+ Winipc.run_blocking { _wait(Winipc.ms_for(timeout)) } == :ok
315
+ end
316
+
317
+ def try_acquire
318
+ acquire(timeout: 0)
319
+ end
320
+
321
+ # Release +count+ units; returns the previous count.
322
+ def release(count = 1)
323
+ _release(count)
324
+ end
325
+
326
+ # Acquire one unit for the duration of the block, then release it. (Use
327
+ # #acquire / #release directly for multi-unit patterns.)
328
+ def synchronize(timeout: nil)
329
+ raise TimeoutError, "winipc: semaphore not acquired within timeout" unless acquire(timeout: timeout)
330
+
331
+ begin
332
+ yield
333
+ ensure
334
+ _release(1)
335
+ end
336
+ end
337
+ end
338
+ end
metadata ADDED
@@ -0,0 +1,121 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: winipc
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - ned
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: rake
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '13.0'
19
+ type: :development
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '13.0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: rake-compiler
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1.2'
33
+ type: :development
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1.2'
40
+ - !ruby/object:Gem::Dependency
41
+ name: minitest
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '5.0'
47
+ type: :development
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '5.0'
54
+ - !ruby/object:Gem::Dependency
55
+ name: vcvars
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - "~>"
59
+ - !ruby/object:Gem::Version
60
+ version: '0.1'
61
+ - - ">="
62
+ - !ruby/object:Gem::Version
63
+ version: 0.1.1
64
+ type: :development
65
+ prerelease: false
66
+ version_requirements: !ruby/object:Gem::Requirement
67
+ requirements:
68
+ - - "~>"
69
+ - !ruby/object:Gem::Version
70
+ version: '0.1'
71
+ - - ">="
72
+ - !ruby/object:Gem::Version
73
+ version: 0.1.1
74
+ description: |
75
+ winipc is a native extension that exposes Windows local inter-process
76
+ communication through an ergonomic, safe-by-default Ruby API: duplex named
77
+ pipes (byte and message mode, with a server and a connect-with-retry client),
78
+ pagefile-backed shared memory via named file mappings, and named
79
+ synchronization objects (mutex, event, semaphore). Pipe handles are opened
80
+ for overlapped I/O so they cooperate with a fiber scheduler, and objects are
81
+ created with a restrictive security descriptor by default. Windows MSVC
82
+ (mswin) Ruby only.
83
+ executables: []
84
+ extensions:
85
+ - ext/winipc/extconf.rb
86
+ extra_rdoc_files: []
87
+ files:
88
+ - CHANGELOG.md
89
+ - LICENSE.txt
90
+ - README.md
91
+ - ext/winipc/extconf.rb
92
+ - ext/winipc/winipc.c
93
+ - lib/winipc.rb
94
+ - lib/winipc/version.rb
95
+ homepage: https://github.com/main-path/winipc
96
+ licenses:
97
+ - MIT
98
+ metadata:
99
+ homepage_uri: https://github.com/main-path/winipc
100
+ changelog_uri: https://github.com/main-path/winipc/blob/main/CHANGELOG.md
101
+ bug_tracker_uri: https://github.com/main-path/winipc/issues
102
+ rubygems_mfa_required: 'true'
103
+ rdoc_options: []
104
+ require_paths:
105
+ - lib
106
+ required_ruby_version: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - ">="
109
+ - !ruby/object:Gem::Version
110
+ version: '3.0'
111
+ required_rubygems_version: !ruby/object:Gem::Requirement
112
+ requirements:
113
+ - - ">="
114
+ - !ruby/object:Gem::Version
115
+ version: '0'
116
+ requirements: []
117
+ rubygems_version: 3.6.9
118
+ specification_version: 4
119
+ summary: 'Windows local IPC for Ruby: named pipes, shared memory, and named synchronization
120
+ objects.'
121
+ test_files: []