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.
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/.github/workflows/ci.yml +9 -2
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/PKG-INFO +16 -1
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/README.md +15 -0
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/examples/echo/echo_plugin.py +23 -1
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/pyproject.toml +1 -1
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/scripts/e2e.py +82 -12
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/src/bowire_plugin/__init__.py +1 -1
- bowire_plugin-0.4.0/src/bowire_plugin/_http.py +211 -0
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/tests/test_http.py +56 -1
- bowire_plugin-0.3.0/src/bowire_plugin/_http.py +0 -127
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/.github/dependabot.yml +0 -0
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/.github/workflows/dependabot-auto-merge.yml +0 -0
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/.github/workflows/release.yml +0 -0
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/.gitignore +0 -0
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/LICENSE +0 -0
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/examples/echo/sidecar.json +0 -0
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/src/bowire_plugin/_manual_entry.py +0 -0
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/src/bowire_plugin/_models.py +0 -0
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/src/bowire_plugin/_plugin.py +0 -0
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/src/bowire_plugin/_runtime.py +0 -0
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/src/bowire_plugin/_settings.py +0 -0
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/tests/test_manual_entry.py +0 -0
- {bowire_plugin-0.3.0 → bowire_plugin-0.4.0}/tests/test_runtime.py +0 -0
- {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:
|
|
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
|
+
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
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
|
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|