bowire-plugin 0.3.0__tar.gz → 0.4.0__tar.gz

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 (24) hide show
  1. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/.github/workflows/ci.yml +9 -2
  2. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/PKG-INFO +16 -1
  3. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/README.md +15 -0
  4. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/examples/echo/echo_plugin.py +23 -1
  5. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/pyproject.toml +1 -1
  6. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/scripts/e2e.py +82 -12
  7. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/src/bowire_plugin/__init__.py +1 -1
  8. bowire_plugin-0.4.0/src/bowire_plugin/_http.py +211 -0
  9. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/tests/test_http.py +56 -1
  10. bowire_plugin-0.3.0/src/bowire_plugin/_http.py +0 -127
  11. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/.github/dependabot.yml +0 -0
  12. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/.github/workflows/dependabot-auto-merge.yml +0 -0
  13. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/.github/workflows/release.yml +0 -0
  14. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/.gitignore +0 -0
  15. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/LICENSE +0 -0
  16. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/examples/echo/sidecar.json +0 -0
  17. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/src/bowire_plugin/_manual_entry.py +0 -0
  18. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/src/bowire_plugin/_models.py +0 -0
  19. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/src/bowire_plugin/_plugin.py +0 -0
  20. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/src/bowire_plugin/_runtime.py +0 -0
  21. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/src/bowire_plugin/_settings.py +0 -0
  22. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/tests/test_manual_entry.py +0 -0
  23. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/tests/test_runtime.py +0 -0
  24. {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/tests/test_settings.py +0 -0
@@ -44,14 +44,19 @@ jobs:
44
44
  # there is the one that runs the plugin. The script then starts the
45
45
  # workbench, sets the example's settings through the Settings dialog's own
46
46
  # endpoint and checks the next invoke and stream use them (#4).
47
+ #
48
+ # Both transports (#8): over stdio the host spawns the sidecar; over http
49
+ # the script starts it on its own, installs a manifest that points at it,
50
+ # runs the same checks, and stops it while the host is still connected.
47
51
  e2e:
48
- name: e2e against Bowire (${{ matrix.os }})
52
+ name: e2e against Bowire (${{ matrix.os }}, ${{ matrix.transport }})
49
53
  runs-on: ${{ matrix.os }}
50
54
  timeout-minutes: 15
51
55
  strategy:
52
56
  fail-fast: false
53
57
  matrix:
54
58
  os: [ubuntu-latest, windows-latest]
59
+ transport: [stdio, http]
55
60
  defaults:
56
61
  run:
57
62
  shell: bash
@@ -79,7 +84,9 @@ jobs:
79
84
  run: dotnet tool install Kuestenlogik.Bowire.Tool --tool-path "$RUNNER_TEMP/bowire-tool"
80
85
 
81
86
  - name: Install the example, discover, and round-trip its settings
82
- run: python scripts/e2e.py --bowire "$RUNNER_TEMP/bowire-tool/bowire" --work "$RUNNER_TEMP/e2e"
87
+ run: >-
88
+ python scripts/e2e.py --transport ${{ matrix.transport }}
89
+ --bowire "$RUNNER_TEMP/bowire-tool/bowire" --work "$RUNNER_TEMP/e2e"
83
90
 
84
91
  gate:
85
92
  name: gate
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: bowire-plugin
3
- Version: 0.3.0
3
+ Version: 0.4.0
4
4
  Summary: Write a Bowire protocol plugin in Python — JSON-RPC sidecar SDK (stdio + HTTP/SSE).
5
5
  Project-URL: Homepage, https://bowire.io/
6
6
  Project-URL: Repository, https://github.com/Kuestenlogik/Bowire.Sdk.Python
@@ -439,6 +439,21 @@ notifications (`$/stream/data`, …). Point the manifest at it with
439
439
  | Best for | local plugin shipped as a zip | hosted / remote / shared by many hosts |
440
440
  | Deps | none | none (stdlib `http.server`) |
441
441
 
442
+ An http sidecar outlives the hosts that use it. Every connected host gets
443
+ every notification on its own SSE stream. A host's `shutdown` is answered,
444
+ and the server keeps serving. `run_http` stops on SIGTERM (what a container
445
+ manager sends) or Ctrl-C, ends the SSE streams hosts hold open, and returns
446
+ 0. Pass `port=0` and `on_listening=` to let the OS pick a free port and
447
+ learn which one it picked:
448
+
449
+ ```python
450
+ run_http(MyPlugin(), port=0, on_listening=lambda port: print(f"listening on {port}"))
451
+ ```
452
+
453
+ The echo example runs this way with `python echo_plugin.py --http
454
+ 127.0.0.1:0`. `scripts/e2e.py --transport http` drives it through a real
455
+ host, and CI runs both transports.
456
+
442
457
  ## API surface
443
458
 
444
459
  | Override | When it's called | Return |
@@ -213,6 +213,21 @@ notifications (`$/stream/data`, …). Point the manifest at it with
213
213
  | Best for | local plugin shipped as a zip | hosted / remote / shared by many hosts |
214
214
  | Deps | none | none (stdlib `http.server`) |
215
215
 
216
+ An http sidecar outlives the hosts that use it. Every connected host gets
217
+ every notification on its own SSE stream. A host's `shutdown` is answered,
218
+ and the server keeps serving. `run_http` stops on SIGTERM (what a container
219
+ manager sends) or Ctrl-C, ends the SSE streams hosts hold open, and returns
220
+ 0. Pass `port=0` and `on_listening=` to let the OS pick a free port and
221
+ learn which one it picked:
222
+
223
+ ```python
224
+ run_http(MyPlugin(), port=0, on_listening=lambda port: print(f"listening on {port}"))
225
+ ```
226
+
227
+ The echo example runs this way with `python echo_plugin.py --http
228
+ 127.0.0.1:0`. `scripts/e2e.py --transport http` drives it through a real
229
+ host, and CI runs both transports.
230
+
216
231
  ## API surface
217
232
 
218
233
  | Override | When it's called | Return |
@@ -14,6 +14,11 @@ uses it — no restart.
14
14
  Run it standalone for a smoke test:
15
15
  echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | python echo_plugin.py
16
16
 
17
+ Or serve it over HTTP/SSE, on its own, for a manifest that says
18
+ ``"transport": "http"`` and points ``"url"`` at it (port 0 lets the OS pick;
19
+ the line printed once it listens says which):
20
+ python echo_plugin.py --http 127.0.0.1:0
21
+
17
22
  Or package echo_plugin.py + sidecar.json into a zip and install:
18
23
  bowire plugin install --file echo-sidecar.zip
19
24
  bowire --url echo://demo
@@ -24,6 +29,7 @@ bit — it is a module the interpreter runs, on every platform alike.
24
29
  """
25
30
  from __future__ import annotations
26
31
 
32
+ import sys
27
33
  from collections.abc import Iterator
28
34
 
29
35
  from bowire_plugin import (
@@ -37,6 +43,7 @@ from bowire_plugin import (
37
43
  SettingOption,
38
44
  Settings,
39
45
  run,
46
+ run_http,
40
47
  )
41
48
 
42
49
 
@@ -140,5 +147,20 @@ class EchoPlugin(BowirePlugin):
140
147
  yield f'{{"tick":{i}}}'
141
148
 
142
149
 
150
+ def _main(argv: list[str]) -> int:
151
+ if not argv:
152
+ return run(EchoPlugin())
153
+ if len(argv) == 2 and argv[0] == "--http":
154
+ host, _, port = argv[1].rpartition(":")
155
+ return run_http(
156
+ EchoPlugin(),
157
+ host=host or "127.0.0.1",
158
+ port=int(port),
159
+ on_listening=lambda bound: print(f"listening on http://{host}:{bound}/", flush=True),
160
+ )
161
+ print("usage: echo_plugin.py [--http <host:port>]", file=sys.stderr)
162
+ return 2
163
+
164
+
143
165
  if __name__ == "__main__":
144
- run(EchoPlugin())
166
+ sys.exit(_main(sys.argv[1:]))
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "bowire-plugin"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Write a Bowire protocol plugin in Python — JSON-RPC sidecar SDK (stdio + HTTP/SSE)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -21,17 +21,25 @@ It is Python rather than shell so the same steps run on Linux and Windows:
21
21
  the manifest names its interpreter, and a manifest that only works on one
22
22
  platform is the failure this exists to catch.
23
23
 
24
- python scripts/e2e.py --bowire <path-to-bowire> --work <empty-temp-dir>
24
+ python scripts/e2e.py --bowire <path-to-bowire> --work <empty-temp-dir> [--transport http]
25
25
 
26
26
  The interpreter the host spawns is whatever ``python`` the caller's ``PATH``
27
27
  resolves — the SDK has to be installed there, the way a plugin author
28
28
  installs it.
29
+
30
+ With ``--transport http`` (#8) the host does not spawn the sidecar: this
31
+ script starts the example in HTTP mode on a port the OS picks, installs a zip
32
+ whose manifest says ``"transport": "http"`` and points ``"url"`` at it, and
33
+ runs the same checks. The sidecar is the script's to stop, not the host's;
34
+ it is stopped while the host is still connected, and has to exit cleanly.
29
35
  """
30
36
  from __future__ import annotations
31
37
 
32
38
  import argparse
33
39
  import json
40
+ import os
34
41
  import re
42
+ import signal
35
43
  import subprocess
36
44
  import sys
37
45
  import threading
@@ -48,6 +56,7 @@ _EXAMPLE = _REPO / "examples" / "echo"
48
56
  _PLUGIN_FILES = ("sidecar.json", "echo_plugin.py")
49
57
  _SERVER_URL = "echo@http://localhost:1"
50
58
  _BANNER = re.compile(r"Bowire is running at:\s+(\S+)")
59
+ _LISTENING = re.compile(r"listening on (http://\S+)")
51
60
 
52
61
  # Loopback only; a proxy from the runner's environment has no business here.
53
62
  _HTTP = urllib.request.build_opener(urllib.request.ProxyHandler({}))
@@ -64,6 +73,52 @@ def _package(work: Path) -> Path:
64
73
  return archive
65
74
 
66
75
 
76
+ def _start_http_sidecar() -> tuple[subprocess.Popen[str], str]:
77
+ """Start the example as an http sidecar and return it with its URL.
78
+
79
+ The example prints its URL once the socket accepts connections, so
80
+ reading its stdout up to that line is the readiness handshake, and
81
+ ``:0`` lets the OS pick the port.
82
+ """
83
+ cmd = [sys.executable, str(_EXAMPLE / "echo_plugin.py"), "--http", "127.0.0.1:0"]
84
+ print("$ " + " ".join(cmd), flush=True)
85
+ proc = subprocess.Popen(cmd, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
86
+ stderr=subprocess.STDOUT, text=True,
87
+ encoding="utf-8", errors="replace")
88
+ assert proc.stdout is not None
89
+ for line in proc.stdout:
90
+ sys.stdout.write(line)
91
+ match = _LISTENING.search(line)
92
+ if match:
93
+ threading.Thread(target=_drain, args=(proc.stdout,), daemon=True).start()
94
+ return proc, match.group(1)
95
+ raise SystemExit(f"the http sidecar exited ({proc.wait()}) before it was listening")
96
+
97
+
98
+ def _package_http(work: Path, url: str) -> Path:
99
+ """A zip with nothing but a manifest: an http sidecar ships no code to
100
+ the host, only where to reach it."""
101
+ stdio = json.loads((_EXAMPLE / "sidecar.json").read_text(encoding="utf-8"))
102
+ manifest = {k: v for k, v in stdio.items() if k not in ("executable", "args", "envPrefix")}
103
+ manifest.update(packageId=stdio["packageId"] + ".Http", transport="http", url=url)
104
+ archive = work / "echo-python-http.zip"
105
+ with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as zf:
106
+ zf.writestr("sidecar.json", json.dumps(manifest, indent=2))
107
+ print(f"packaged {archive}: sidecar.json -> {url}", flush=True)
108
+ return archive
109
+
110
+
111
+ def _stop_http_sidecar(proc: subprocess.Popen[str]) -> None:
112
+ """Stop the sidecar the way a container manager does, and require a
113
+ clean exit. On Windows there is no such signal a process can handle,
114
+ so there the exit is only awaited."""
115
+ proc.send_signal(signal.SIGTERM)
116
+ code = proc.wait()
117
+ if os.name != "nt" and code != 0:
118
+ raise SystemExit(f"the http sidecar did not stop cleanly: exit {code}")
119
+ print(f"http sidecar: stopped with the host still connected (exit {code})", flush=True)
120
+
121
+
67
122
  def _bowire(bowire: str, plugin_dir: Path, *args: str) -> str:
68
123
  cmd = [bowire, "--plugin-dir", str(plugin_dir), *args]
69
124
  print("$ " + " ".join(cmd), flush=True)
@@ -158,7 +213,8 @@ def _manual_entry(base: str) -> None:
158
213
  print("manual entry: the declared form is served", flush=True)
159
214
 
160
215
 
161
- def _settings_round_trip(bowire: str, plugin_dir: Path, storage: Path) -> None:
216
+ def _settings_round_trip(bowire: str, plugin_dir: Path, storage: Path,
217
+ sidecar: subprocess.Popen[str] | None) -> None:
162
218
  storage.mkdir(parents=True, exist_ok=True)
163
219
  proc, base = _serve(bowire, plugin_dir)
164
220
  try:
@@ -203,6 +259,8 @@ def _settings_round_trip(bowire: str, plugin_dir: Path, storage: Path) -> None:
203
259
  raise SystemExit("invokeStream did not see the settings set for it")
204
260
 
205
261
  _manual_entry(base)
262
+ if sidecar is not None:
263
+ _stop_http_sidecar(sidecar)
206
264
  finally:
207
265
  proc.terminate()
208
266
  proc.wait()
@@ -213,23 +271,35 @@ def main() -> int:
213
271
  parser.add_argument("--bowire", required=True, help="the bowire executable to drive")
214
272
  parser.add_argument("--work", required=True, type=Path,
215
273
  help="an empty directory for the zip and the plugin tree")
274
+ parser.add_argument("--transport", choices=("stdio", "http"), default="stdio",
275
+ help="spawned by the host (stdio) or running on its own (http)")
216
276
  opts = parser.parse_args()
217
277
 
218
278
  work: Path = opts.work.resolve()
219
279
  work.mkdir(parents=True, exist_ok=True)
220
280
  plugin_dir = work / "plugins"
221
281
 
222
- archive = _package(work)
223
- _bowire(opts.bowire, plugin_dir, "plugin", "install", "--file", str(archive))
224
- _bowire(opts.bowire, plugin_dir, "plugin", "list", "--verbose")
225
-
226
- out = _bowire(opts.bowire, plugin_dir, "discover", "--url", "echo@http://localhost:1")
227
- if "Echo" not in out:
228
- raise SystemExit("discover succeeded but listed no Echo service")
229
-
230
- _settings_round_trip(opts.bowire, plugin_dir, work / "workspace")
282
+ sidecar: subprocess.Popen[str] | None = None
283
+ try:
284
+ if opts.transport == "http":
285
+ sidecar, url = _start_http_sidecar()
286
+ archive = _package_http(work, url)
287
+ else:
288
+ archive = _package(work)
289
+ _bowire(opts.bowire, plugin_dir, "plugin", "install", "--file", str(archive))
290
+ _bowire(opts.bowire, plugin_dir, "plugin", "list", "--verbose")
291
+
292
+ out = _bowire(opts.bowire, plugin_dir, "discover", "--url", "echo@http://localhost:1")
293
+ if "Echo" not in out:
294
+ raise SystemExit("discover succeeded but listed no Echo service")
295
+
296
+ _settings_round_trip(opts.bowire, plugin_dir, work / "workspace", sidecar)
297
+ finally:
298
+ if sidecar is not None and sidecar.poll() is None:
299
+ sidecar.kill()
300
+ sidecar.wait()
231
301
 
232
- print("e2e: ok", flush=True)
302
+ print(f"e2e: ok ({opts.transport})", flush=True)
233
303
  return 0
234
304
 
235
305
 
@@ -46,7 +46,7 @@ from ._plugin import BowirePlugin
46
46
  from ._runtime import SIDECAR_PROTOCOL_VERSION, run
47
47
  from ._settings import Settings
48
48
 
49
- __version__ = "0.3.0"
49
+ __version__ = "0.4.0"
50
50
 
51
51
  __all__ = [
52
52
  "SIDECAR_PROTOCOL_VERSION",
@@ -0,0 +1,211 @@
1
+ # Copyright 2026 Küstenlogik
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ """HTTP/SSE server transport — the MCP-style streamable-HTTP shape.
4
+
5
+ The same :class:`_Dispatcher` that powers the stdio runtime drives an
6
+ HTTP service here: JSON-RPC requests arrive as ``POST`` (the HTTP
7
+ response body carries the reply); server-initiated notifications
8
+ (``$/stream/data`` …) stream back over a long-lived SSE ``GET`` on the
9
+ same path. Bowire's host wires to this with a ``"transport": "http"``
10
+ sidecar manifest pointing at the URL.
11
+
12
+ Unlike a stdio sidecar, an http one is not its host's: it runs on its own,
13
+ any number of hosts connect to it over its lifetime, and each holds an SSE
14
+ stream open while it uses the sidecar. So every subscriber gets every
15
+ notification, a host's ``shutdown`` is acknowledged without stopping the
16
+ server, and the server stops when its process is told to (SIGTERM, Ctrl-C)
17
+ or :meth:`_HttpServer.shutdown` is called.
18
+
19
+ Pure stdlib (``http.server``) — no extra dependencies.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ import json
24
+ import queue
25
+ import signal
26
+ import sys
27
+ import threading
28
+ from collections.abc import Callable
29
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
30
+ from typing import Any
31
+
32
+ from ._plugin import BowirePlugin
33
+ from ._runtime import _Dispatcher
34
+
35
+ # Sentinel pushed onto every subscriber's queue to end its SSE stream.
36
+ _STOP = object()
37
+
38
+
39
+ class _Subscribers:
40
+ """The SSE streams open right now, one queue each.
41
+
42
+ One shared queue used to feed them all, so subscribers took turns: a
43
+ host that had gone away could still take an event off the queue, fail
44
+ to write it and drop it, and the host that was waiting for it never got
45
+ it. Each subscriber now has a queue of its own, and a notification goes
46
+ into all of them.
47
+ """
48
+
49
+ def __init__(self) -> None:
50
+ self._lock = threading.Lock()
51
+ self._queues: set[queue.Queue[Any]] = set()
52
+ self._closed = False
53
+
54
+ def add(self) -> queue.Queue[Any]:
55
+ q: queue.Queue[Any] = queue.Queue()
56
+ with self._lock:
57
+ if self._closed:
58
+ q.put(_STOP)
59
+ else:
60
+ self._queues.add(q)
61
+ return q
62
+
63
+ def remove(self, q: queue.Queue[Any]) -> None:
64
+ with self._lock:
65
+ self._queues.discard(q)
66
+
67
+ def emit(self, event: Any) -> None:
68
+ with self._lock:
69
+ for q in self._queues:
70
+ q.put(event)
71
+
72
+ def close(self) -> None:
73
+ with self._lock:
74
+ self._closed = True
75
+ for q in self._queues:
76
+ q.put(_STOP)
77
+
78
+
79
+ class _Server(ThreadingHTTPServer):
80
+ """A host that goes away resets its keep-alive connections; that is a
81
+ host leaving, not an error, so it is not printed as a traceback."""
82
+
83
+ def handle_error(self, request: Any, client_address: Any) -> None:
84
+ if isinstance(sys.exc_info()[1], (ConnectionResetError, ConnectionAbortedError, BrokenPipeError)):
85
+ return
86
+ super().handle_error(request, client_address)
87
+
88
+
89
+ class _HttpServer:
90
+ """A running HTTP/SSE sidecar server. Reusable from tests
91
+ (start/port/shutdown); :func:`run_http` wraps it for the blocking
92
+ process entry point."""
93
+
94
+ def __init__(self, plugin: BowirePlugin, host: str = "127.0.0.1", port: int = 8770) -> None:
95
+ subscribers = _Subscribers()
96
+ dispatcher = _Dispatcher(plugin, subscribers.emit)
97
+ self._subscribers = subscribers
98
+ self._dispatcher = dispatcher
99
+
100
+ class Handler(BaseHTTPRequestHandler):
101
+ protocol_version = "HTTP/1.1"
102
+
103
+ def log_message(self, *args: Any) -> None: # silence default stderr logging
104
+ pass
105
+
106
+ def do_POST(self) -> None:
107
+ length = int(self.headers.get("Content-Length", "0") or "0")
108
+ raw = self.rfile.read(length) if length else b""
109
+ try:
110
+ msg = json.loads(raw)
111
+ except json.JSONDecodeError:
112
+ self._send_json(400, {"error": "invalid json"})
113
+ return
114
+ # A host's `shutdown` is the host leaving, not the sidecar
115
+ # being told to stop: it is answered, and the server keeps
116
+ # serving the hosts still connected and the ones to come.
117
+ reply, post = dispatcher.handle(msg.get("method"), msg.get("id"), msg.get("params") or {})
118
+ self._send_json(200, reply if reply is not None else {})
119
+ if post is not None:
120
+ post() # start streaming only after the ack response is sent
121
+
122
+ def do_GET(self) -> None:
123
+ # Subscribed before the headers go out, so a host that has
124
+ # them can rely on getting every notification after.
125
+ q = subscribers.add()
126
+ try:
127
+ self.send_response(200)
128
+ self.send_header("Content-Type", "text/event-stream")
129
+ self.send_header("Cache-Control", "no-cache")
130
+ self.send_header("Connection", "keep-alive")
131
+ self.end_headers()
132
+ while True:
133
+ evt = q.get()
134
+ if evt is _STOP:
135
+ break
136
+ payload = json.dumps(evt, separators=(",", ":"), ensure_ascii=False)
137
+ self.wfile.write(f"data: {payload}\n\n".encode())
138
+ self.wfile.flush()
139
+ except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError):
140
+ pass # the host went away
141
+ finally:
142
+ subscribers.remove(q)
143
+ # The stream ends with this response; the connection is
144
+ # not reused for another request.
145
+ self.close_connection = True
146
+
147
+ def _send_json(self, status: int, body: dict[str, Any]) -> None:
148
+ data = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode()
149
+ self.send_response(status)
150
+ self.send_header("Content-Type", "application/json")
151
+ self.send_header("Content-Length", str(len(data)))
152
+ self.end_headers()
153
+ self.wfile.write(data)
154
+
155
+ self._server = _Server((host, port), Handler)
156
+
157
+ @property
158
+ def port(self) -> int:
159
+ return self._server.server_address[1]
160
+
161
+ def serve_forever(self) -> None:
162
+ try:
163
+ self._server.serve_forever()
164
+ finally:
165
+ self._subscribers.close()
166
+ self._dispatcher.join_streams(timeout=2)
167
+ self._server.server_close()
168
+
169
+ def shutdown(self) -> None:
170
+ """Stop serving: end every open SSE stream, then let
171
+ :meth:`serve_forever` return. Safe from any thread but the one
172
+ running :meth:`serve_forever`."""
173
+ self._subscribers.close()
174
+ self._server.shutdown()
175
+
176
+
177
+ def run_http(
178
+ plugin: BowirePlugin,
179
+ *,
180
+ host: str = "127.0.0.1",
181
+ port: int = 8770,
182
+ on_listening: Callable[[int], None] | None = None,
183
+ ) -> int:
184
+ """Serve ``plugin`` over HTTP/SSE on ``host:port`` until the process is
185
+ told to stop: SIGTERM (what a container manager sends) or Ctrl-C. Blocks;
186
+ returns 0 once it has stopped cleanly. A host's ``shutdown`` does not
187
+ stop it: an http sidecar outlives the hosts that connect to it.
188
+
189
+ ``on_listening`` is called with the bound port once the socket accepts
190
+ connections, before serving starts: with ``port=0`` the OS picks the
191
+ port, and this is how a caller learns which one.
192
+
193
+ Point a sidecar manifest at it:
194
+
195
+ { "transport": "http", "url": "http://127.0.0.1:8770/" }
196
+ """
197
+ server = _HttpServer(plugin, host, port)
198
+
199
+ # serve_forever runs on this thread, and shutdown() waits for it to
200
+ # return, so the handlers hand the stop to a thread of its own.
201
+ def stop(_signum: int, _frame: object) -> None:
202
+ threading.Thread(target=server.shutdown, daemon=True).start()
203
+
204
+ if threading.current_thread() is threading.main_thread():
205
+ signal.signal(signal.SIGTERM, stop)
206
+ signal.signal(signal.SIGINT, stop)
207
+
208
+ if on_listening is not None:
209
+ on_listening(server.port)
210
+ server.serve_forever()
211
+ return 0
@@ -107,6 +107,61 @@ def test_http_unknown_method_errors(server):
107
107
  assert out["error"]["code"] == -32601
108
108
 
109
109
 
110
- def test_http_shutdown_stops_serving(server):
110
+ def test_a_hosts_shutdown_is_answered_and_the_server_keeps_serving(server):
111
+ # An http sidecar outlives its hosts: the shutdown a host sends when it
112
+ # lets go of the sidecar must not stop it for the next one.
111
113
  out = _post(server.port, {"jsonrpc": "2.0", "id": 6, "method": "shutdown", "params": {}})
112
114
  assert out["result"] is True
115
+ again = _post(server.port, {"jsonrpc": "2.0", "id": 7, "method": "ping", "params": {}})
116
+ assert again["result"] == "pong"
117
+
118
+
119
+ def _stream_frames(sse) -> list[str]:
120
+ frames: list[str] = []
121
+ for raw in sse:
122
+ line = raw.decode().strip()
123
+ if not line.startswith("data:"):
124
+ continue
125
+ note = json.loads(line[5:].strip())
126
+ if note.get("method") == "$/stream/data":
127
+ frames.append(note["params"]["message"])
128
+ elif note.get("method") == "$/stream/end":
129
+ return frames
130
+ raise AssertionError(f"the stream ended before $/stream/end; got {frames}")
131
+
132
+
133
+ def _invoke_stream(port: int, stream_id: str) -> None:
134
+ _post(port, {"jsonrpc": "2.0", "id": 8, "method": "invokeStream",
135
+ "params": {"streamId": stream_id, "serverUrl": "httpfake://x",
136
+ "service": "Echo", "method": "Echo/echo", "jsonMessages": [],
137
+ "showInternalServices": False, "metadata": {}}})
138
+
139
+
140
+ def test_every_subscriber_gets_every_frame(server):
141
+ # The subscribers used to share one queue and take turns: with two of
142
+ # them, each frame reached only one, so a host lost frames to a stream
143
+ # it was not reading - one left behind by a host that had gone.
144
+ first = urllib.request.urlopen(f"http://127.0.0.1:{server.port}/", timeout=5)
145
+ second = urllib.request.urlopen(f"http://127.0.0.1:{server.port}/", timeout=5)
146
+ try:
147
+ _invoke_stream(server.port, "s1")
148
+ expected = ['{"tick":1}', '{"tick":2}', '{"tick":3}']
149
+ assert _stream_frames(first) == expected
150
+ assert _stream_frames(second) == expected
151
+ finally:
152
+ first.close()
153
+ second.close()
154
+
155
+
156
+ def test_stopping_ends_the_open_sse_streams():
157
+ srv = _HttpServer(_Fake(), host="127.0.0.1", port=0)
158
+ t = threading.Thread(target=srv.serve_forever, daemon=True)
159
+ t.start()
160
+ sse = urllib.request.urlopen(f"http://127.0.0.1:{srv.port}/", timeout=5)
161
+ try:
162
+ srv.shutdown()
163
+ assert sse.read() == b"" # the stream ended rather than hanging
164
+ t.join()
165
+ assert not t.is_alive()
166
+ finally:
167
+ sse.close()
@@ -1,127 +0,0 @@
1
- # Copyright 2026 Küstenlogik
2
- # SPDX-License-Identifier: Apache-2.0
3
- """HTTP/SSE server transport — the MCP-style streamable-HTTP shape.
4
-
5
- The same :class:`_Dispatcher` that powers the stdio runtime drives an
6
- HTTP service here: JSON-RPC requests arrive as ``POST`` (the HTTP
7
- response body carries the reply); server-initiated notifications
8
- (``$/stream/data`` / ``$/channel/data`` …) stream back over one
9
- long-lived SSE ``GET`` on the same path. Bowire's host wires to this
10
- with a ``"transport": "http"`` sidecar manifest pointing at the URL.
11
-
12
- Pure stdlib (``http.server``) — no extra dependencies.
13
- """
14
- from __future__ import annotations
15
-
16
- import json
17
- import queue
18
- import threading
19
- from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
20
- from typing import Any
21
-
22
- from ._plugin import BowirePlugin
23
- from ._runtime import _Dispatcher
24
-
25
- # Sentinel pushed onto the SSE queue to wake the GET loop for shutdown.
26
- _STOP = object()
27
-
28
-
29
- class _HttpServer:
30
- """A running HTTP/SSE sidecar server. Reusable from tests
31
- (start/port/shutdown); :func:`run_http` wraps it for the blocking
32
- process entry point."""
33
-
34
- def __init__(self, plugin: BowirePlugin, host: str = "127.0.0.1", port: int = 8770) -> None:
35
- # One SSE event queue drained by the connected GET client. emit()
36
- # from the dispatcher (incl. its stream-pump threads) lands here.
37
- self._sse: queue.Queue[Any] = queue.Queue()
38
- self._dispatcher = _Dispatcher(plugin, self._sse.put)
39
- self._stop = threading.Event()
40
-
41
- dispatcher = self._dispatcher
42
- sse = self._sse
43
- stop = self._stop
44
-
45
- class Handler(BaseHTTPRequestHandler):
46
- protocol_version = "HTTP/1.1"
47
-
48
- def log_message(self, *args: Any) -> None: # silence default stderr logging
49
- pass
50
-
51
- def do_POST(self) -> None:
52
- length = int(self.headers.get("Content-Length", "0") or "0")
53
- raw = self.rfile.read(length) if length else b""
54
- try:
55
- msg = json.loads(raw)
56
- except json.JSONDecodeError:
57
- self._send_json(400, {"error": "invalid json"})
58
- return
59
- reply, post = dispatcher.handle(msg.get("method"), msg.get("id"), msg.get("params") or {})
60
- self._send_json(200, reply if reply is not None else {})
61
- if post is not None:
62
- post() # start streaming only after the ack response is sent
63
- if dispatcher.stopped:
64
- # shutdown handled — wake the SSE loop + stop serving
65
- # (on a separate thread; can't stop from a handler).
66
- stop.set()
67
- sse.put(_STOP)
68
- threading.Thread(target=self.server.shutdown, daemon=True).start()
69
-
70
- def do_GET(self) -> None:
71
- self.send_response(200)
72
- self.send_header("Content-Type", "text/event-stream")
73
- self.send_header("Cache-Control", "no-cache")
74
- self.send_header("Connection", "keep-alive")
75
- self.end_headers()
76
- while not stop.is_set():
77
- try:
78
- evt = sse.get(timeout=0.5)
79
- except queue.Empty:
80
- continue
81
- if evt is _STOP:
82
- break
83
- try:
84
- payload = json.dumps(evt, separators=(",", ":"), ensure_ascii=False)
85
- self.wfile.write(f"data: {payload}\n\n".encode())
86
- self.wfile.flush()
87
- except (BrokenPipeError, ConnectionResetError):
88
- break
89
-
90
- def _send_json(self, status: int, body: dict[str, Any]) -> None:
91
- data = json.dumps(body, separators=(",", ":"), ensure_ascii=False).encode()
92
- self.send_response(status)
93
- self.send_header("Content-Type", "application/json")
94
- self.send_header("Content-Length", str(len(data)))
95
- self.end_headers()
96
- self.wfile.write(data)
97
-
98
- self._server = ThreadingHTTPServer((host, port), Handler)
99
-
100
- @property
101
- def port(self) -> int:
102
- return self._server.server_address[1]
103
-
104
- def serve_forever(self) -> None:
105
- try:
106
- self._server.serve_forever()
107
- finally:
108
- self._stop.set()
109
- self._dispatcher.join_streams(timeout=2)
110
- self._server.server_close()
111
-
112
- def shutdown(self) -> None:
113
- self._stop.set()
114
- self._sse.put(_STOP)
115
- self._server.shutdown()
116
-
117
-
118
- def run_http(plugin: BowirePlugin, *, host: str = "127.0.0.1", port: int = 8770) -> int:
119
- """Serve ``plugin`` over HTTP/SSE on ``host:port`` until the host
120
- sends ``shutdown``. Blocks; returns 0.
121
-
122
- Point a sidecar manifest at it:
123
-
124
- { "transport": "http", "url": "http://127.0.0.1:8770/" }
125
- """
126
- _HttpServer(plugin, host, port).serve_forever()
127
- return 0
File without changes
File without changes