samagotchi 0.121.0 → 0.121.1
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/CHANGELOG.md +82 -1
- data/bin/chi +3 -2
- data/docs/architecture.md +36 -11
- data/docs/bundle-authoring.md +2 -1
- data/docs/cli.md +10 -4
- data/docs/configuration.md +7 -3
- data/docs/guardrails.md +48 -19
- data/docs/internals/anthropic.md +1 -1
- data/docs/internals/background-tasks.md +1 -1
- data/docs/internals/participants.md +3 -1
- data/docs/internals/remote-sessions.md +15 -11
- data/docs/internals/web-push.md +22 -11
- data/docs/plugins.md +6 -2
- data/docs/sessions.md +15 -0
- data/docs/testing.md +41 -1
- data/lib/samagotchi/answer_command.rb +1 -1
- data/lib/samagotchi/bridge.rb +8 -8
- data/lib/samagotchi/bridge_client/event_stream.rb +11 -27
- data/lib/samagotchi/bridge_client/stream_open.rb +79 -0
- data/lib/samagotchi/bridge_client/stream_request.rb +8 -2
- data/lib/samagotchi/bridge_client.rb +52 -65
- data/lib/samagotchi/bridge_secret.rb +9 -7
- data/lib/samagotchi/bundle_command/check.rb +27 -2
- data/lib/samagotchi/bundles/claude-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/claude-peer/scripts/pair +20 -6
- data/lib/samagotchi/bundles/codex-peer/manifest.yml +2 -2
- data/lib/samagotchi/bundles/codex-peer/scripts/pair +14 -3
- data/lib/samagotchi/bundles/github-pr/manifest.yml +2 -2
- data/lib/samagotchi/bundles/github-pr/plugin.rb +2 -6
- data/lib/samagotchi/bundles/system/manifest.yml +1 -1
- data/lib/samagotchi/child_move.rb +5 -7
- data/lib/samagotchi/children_status.rb +2 -1
- data/lib/samagotchi/guardrail_wiring.rb +19 -2
- data/lib/samagotchi/guardrails/parent_approvals.rb +9 -0
- data/lib/samagotchi/guardrails_command.rb +41 -6
- data/lib/samagotchi/image_store.rb +6 -3
- data/lib/samagotchi/interrupted_turn.rb +6 -3
- data/lib/samagotchi/live_versions.rb +6 -7
- data/lib/samagotchi/memory_bundle/profile.rb +1 -1
- data/lib/samagotchi/memory_bundle/uninstaller.rb +6 -2
- data/lib/samagotchi/plugin/api.rb +8 -5
- data/lib/samagotchi/plugin/sessions.rb +13 -4
- data/lib/samagotchi/push_command.rb +1 -1
- data/lib/samagotchi/relay_verifier.rb +2 -3
- data/lib/samagotchi/relay_watcher.rb +2 -2
- data/lib/samagotchi/remote_attach_command.rb +1 -1
- data/lib/samagotchi/reply_wait/poll.rb +8 -2
- data/lib/samagotchi/reply_wait.rb +4 -3
- data/lib/samagotchi/review_command.rb +1 -1
- data/lib/samagotchi/run_options.rb +1 -0
- data/lib/samagotchi/session.rb +4 -0
- data/lib/samagotchi/session_access/local.rb +18 -8
- data/lib/samagotchi/session_access/remote.rb +5 -0
- data/lib/samagotchi/session_access/web_bridge_client.rb +31 -19
- data/lib/samagotchi/session_access.rb +21 -9
- data/lib/samagotchi/session_continue.rb +2 -3
- data/lib/samagotchi/session_manager.rb +118 -28
- data/lib/samagotchi/sessions_command.rb +10 -5
- data/lib/samagotchi/system_one/client.rb +6 -1
- data/lib/samagotchi/system_one/fake_backend.rb +0 -2
- data/lib/samagotchi/system_one/log.rb +21 -6
- data/lib/samagotchi/system_one/self_protect.rb +39 -43
- data/lib/samagotchi/terminal_ui/attach_launcher.rb +8 -3
- data/lib/samagotchi/terminal_ui/attached_loop.rb +4 -2
- data/lib/samagotchi/terminal_ui/local_link.rb +28 -19
- data/lib/samagotchi/toolkit.rb +68 -2
- data/lib/samagotchi/tools/delegate.rb +1 -1
- data/lib/samagotchi/tools/delegate_relay.rb +9 -7
- data/lib/samagotchi/turn_checkpoint.rb +15 -9
- data/lib/samagotchi/version.rb +1 -1
- data/lib/samagotchi/web/app.rb +123 -65
- data/lib/samagotchi/web/participant_access.rb +26 -7
- data/lib/samagotchi/web/participant_review.rb +2 -1
- data/lib/samagotchi/web/participant_view.rb +7 -3
- data/lib/samagotchi/web/participants_admin.rb +13 -0
- data/lib/samagotchi/web/public/app.js +1 -1
- data/lib/samagotchi/web/public/notifications.js +21 -1
- data/lib/samagotchi/web/public/participant.html +3 -2
- data/lib/samagotchi/web/public/participant.js +23 -9
- data/lib/samagotchi/web/public/participants_panel.js +46 -11
- data/lib/samagotchi/web/public/push.js +4 -3
- data/lib/samagotchi/web/public/push_devices.js +17 -3
- data/lib/samagotchi/web/public/review_history.js +14 -2
- data/lib/samagotchi/web/public/review_start.js +1 -3
- data/lib/samagotchi/web/public/session_actions.js +14 -2
- data/lib/samagotchi/web/public/storage.js +16 -0
- data/lib/samagotchi/web/push/client.rb +5 -0
- data/lib/samagotchi/web/push/store.rb +16 -3
- data/lib/samagotchi/web/push_routes.rb +3 -2
- data/lib/samagotchi/web/session_access_routes.rb +25 -17
- data/lib/samagotchi/web/session_create.rb +4 -4
- data/lib/samagotchi/web/session_hub.rb +6 -4
- data/lib/samagotchi/web/session_summary.rb +8 -16
- data/lib/samagotchi/worker.rb +1 -1
- data/lib/samagotchi/worker_link.rb +213 -0
- data/lib/samagotchi/worker_sidecar.rb +15 -37
- metadata +3 -1
data/docs/internals/web-push.md
CHANGED
|
@@ -66,10 +66,11 @@ files, one per machine, never in config.yml:
|
|
|
66
66
|
`Store#add` checks what `pushManager.subscribe` gave, one message per refusal (the route answers it as 400
|
|
67
67
|
`invalid_subscription`): an endpoint and both keys; https on port 443 without userinfo, at most 2048 bytes; a host in
|
|
68
68
|
`Store::PUSH_SERVICES` (Apple, Google's FCM, Mozilla, `*.notify.windows.com`; a `services:` seam for specs, not
|
|
69
|
-
config): chi web POSTs to that URL, so it never takes an arbitrary host; `p256dh` a 65-byte uncompressed point
|
|
70
|
-
`auth` 16 bytes. The same endpoint again replaces the old entry.
|
|
71
|
-
subscribe, which the page keeps) drops that entry on this origin too,
|
|
72
|
-
yields it (the route logs `push_replaced`); its own endpoint is never
|
|
69
|
+
config): chi web POSTs to that URL, so it never takes an arbitrary host; `p256dh` a 65-byte uncompressed point
|
|
70
|
+
on P-256 (built as `Crypto.ec_public` builds it), `auth` 16 bytes. The same endpoint again replaces the old entry.
|
|
71
|
+
`replaces:` (the device's id from its last subscribe, which the page keeps) drops that entry on this origin too,
|
|
72
|
+
before the `MAX_SUBS` cap is counted, and yields it (the route logs `push_replaced`); its own endpoint is never
|
|
73
|
+
dropped that way, and an unknown id is ignored.
|
|
73
74
|
That is how a subscription iOS revoked silently (Apple keeps answering 201, so `:gone` never comes) leaves the store:
|
|
74
75
|
the user turns the bell off and on, the page has no live subscription to delete, and the new one replaces it. Every write is a read-change-write under
|
|
75
76
|
`store.lock` (chi web's request threads and the sender thread both write). A `subscriptions.json` that doesn't read
|
|
@@ -85,7 +86,9 @@ tailnet leaves the old ones stale (never sent to: a tap would open an origin tha
|
|
|
85
86
|
`Client#deliver(sub, payload, ttl:, urgency:)` encrypts the payload's JSON, POSTs it over TLS (open 5 s, read 10 s,
|
|
86
87
|
no redirects, no retry of its own) with `Content-Encoding: aes128gcm`, `TTL`, `Urgency` and the VAPID header, and
|
|
87
88
|
answers a `Client::Result`: `:ok` (200/201/202), `:gone` (404/410: the device dropped it), `:bad_jwt` (403 with
|
|
88
|
-
`BadJwtToken`), `:failed` (anything else; a connection error as its class name or `timeout`, never its message
|
|
89
|
+
`BadJwtToken`), `:failed` (anything else; a connection error as its class name or `timeout`, never its message;
|
|
90
|
+
any other error too, so a key off the curve on disk fails that device alone, never the Notifier's flush or the test
|
|
91
|
+
push).
|
|
89
92
|
A 429 or 5xx also carries `retry_after`: its `Retry-After` delta-seconds up to `RETRY_MAX` 60, else (missing, an
|
|
90
93
|
HTTP date) `RETRY_DEFAULT` 10; nil for anything else, a connection error too (it may have been delivered). The
|
|
91
94
|
transport is injectable: `spec/web/push/client_spec.rb` runs a fake push service (WEBrick, plain http through the
|
|
@@ -206,7 +209,8 @@ public URL, or `web.push.enabled: false`):
|
|
|
206
209
|
- `POST /api/push/subscriptions {subscription: {endpoint, keys}, replaces?: id}` → 201 `{id, label}`; the label is
|
|
207
210
|
chi's own ("iPhone · <date>", from the User-Agent's device class: iPhone, iPad, Mac, else Browser); `replaces`
|
|
208
211
|
drops the device's earlier entry (`Store#add`).
|
|
209
|
-
- `DELETE /api/push/subscriptions {endpoint}` → 204: this device's bell turned off
|
|
212
|
+
- `DELETE /api/push/subscriptions {endpoint}` → 204: this device's bell turned off (`Store#remove(endpoint, origin:)`:
|
|
213
|
+
only a device on this origin goes, as with the other device routes).
|
|
210
214
|
- `DELETE /api/push/subscriptions/ID` → 204, 404 `unknown_device` (another origin's, or not a short id); logs
|
|
211
215
|
`push_removed`. The devices sheet's Remove for another device; this device's own row never uses it (its switch is
|
|
212
216
|
the bell, so the page's state and the store stay one).
|
|
@@ -245,9 +249,13 @@ Screen app the bell (`notifications.js`) registers `/sw.js`, asks `GET /api/push
|
|
|
245
249
|
and is "push to this phone" when chi web pushes: on while chi web has this device. A tap asks permission from the tap
|
|
246
250
|
(iOS asks only from a user gesture), subscribes, posts it (with `replaces`: the id chi web answered last time,
|
|
247
251
|
kept in localStorage as `chi_push_device`, `PUSH_DEVICE_KEY`; a storage that throws only loses the replace) and sends
|
|
248
|
-
a test push; a tap when on
|
|
249
|
-
|
|
250
|
-
|
|
252
|
+
a test push; a tap when on deletes, then unsubscribes (a DELETE that fails changes nothing local: the bell stays on
|
|
253
|
+
and says the error), and forgets the id, unless there was no subscription left to delete (revoked meanwhile): then
|
|
254
|
+
the id stays for the next subscribe to replace. The tab's own OS notification is left to the push there; the title
|
|
255
|
+
count stays. A `GET /api/push` (or `register()`) that fails at launch (chi web restarting, a phone just woken) leaves
|
|
256
|
+
the bell the in-tab one until the page next comes back in front (`backInFront` → `retryPush`, one at a time, no
|
|
257
|
+
timer); chi web answering that it doesn't push is not asked again. In a browser tab, or with chi web not pushing,
|
|
258
|
+
the bell is the in-tab one, unchanged (no duplicate desktop pushes).
|
|
251
259
|
|
|
252
260
|
**The devices sheet** (`public/push_devices.js`, `createPushDevices` on `chip_popover.js`'s shell: a bottom sheet
|
|
253
261
|
under 600 px, else a popover at the bell; made once chi web says it pushes, so a tab never gets one): in push mode
|
|
@@ -273,8 +281,11 @@ page's `byId` (`push.js openPushKeys`: `Attention.open_keys`'s rule), and sets o
|
|
|
273
281
|
badge (`navigator.setAppBadge` / `clearAppBadge`); the bell turned off clears it. It runs once push is known, when
|
|
274
282
|
the page comes back in front, and after each hub frame while visible (so answering on the Mac with the app open
|
|
275
283
|
clears it at once), one at a time with one rerun. Never before the first snapshot (an empty `byId` would close
|
|
276
|
-
everything)
|
|
277
|
-
|
|
284
|
+
everything). The device's notifications are every project's, so on a project's page (`?dir=`, or the bare page on
|
|
285
|
+
chi web's start project: `createNotifications({scoped})`, app.js passes `!!scope.dir`) the tidy-up goes by a stream
|
|
286
|
+
of every session of its own (`watchAll`, through `allEvents`: data.js `openEvents` without a dir), not the page's
|
|
287
|
+
`/api/events?dir=` frames, which leave out the other projects' sessions. That stream is open only with the bell on
|
|
288
|
+
(turning it off closes it), one at a time, and one that ended is opened again when the page next comes back in front.
|
|
278
289
|
|
|
279
290
|
Accepted staleness: an approval answered elsewhere keeps its banner until the app opens (the worker's cleanup on the next push doesn't work on iOS yet: see iOS facts)
|
|
280
291
|
(or a swipe), and the icon badge may be too high until then; a show already handed to Apple just before its answer
|
data/docs/plugins.md
CHANGED
|
@@ -587,7 +587,8 @@ config or bundle toolkit) with `add` declared and `remove` left out, and
|
|
|
587
587
|
inherits the base's, `""` clears it; at most 2000 characters, and one that
|
|
588
588
|
isn't a String or is longer is a load error). `groups` selects the source
|
|
589
589
|
groups the toolkit reaches (names or globs, sorted and deduplicated; nil
|
|
590
|
-
inherits the base's, `[]` reaches none, and `:all` reaches every group
|
|
590
|
+
inherits the base's, `[]` reaches none, and `:all` reaches every group; anything
|
|
591
|
+
else, a map say, is a load error).
|
|
591
592
|
Groups match against the tool index's load-time names. Users pick
|
|
592
593
|
it like chi's own (`chi --toolkit NAME`, `toolkit.default`, `/toolkit NAME`),
|
|
593
594
|
and the plugin with `ctx.use_toolkit` (below). Names are `a-z`, `0-9`, `_` and
|
|
@@ -597,7 +598,7 @@ tool (full) until it comes back, with a warning at its first turn. See
|
|
|
597
598
|
[Toolkits](configuration.md#toolkits). `prompt:` needs chi 0.72.0 (an older
|
|
598
599
|
chi refuses the keyword, a load error: a bundle that passes it sets
|
|
599
600
|
`requires_chi: ">= 0.72.0"`). `groups:` needs chi 0.121.0: an older chi refuses that keyword
|
|
600
|
-
too, so a bundle passing it sets `requires_chi: ">= 0.121.0"
|
|
601
|
+
too, so a bundle passing it sets `requires_chi: ">= 0.121.0"` (`chi bundle check` warns on a lower one).
|
|
601
602
|
|
|
602
603
|
```ruby
|
|
603
604
|
chi.toolkit "review", base: "lean", add: %w[delegate delegate_result]
|
|
@@ -1014,6 +1015,9 @@ ctx.sessions.review(subjects: ["docs/plan.md"], seed: handoff) # => a review se
|
|
|
1014
1015
|
`/title` of its own (see [Session titles](sessions.md#session-titles)).
|
|
1015
1016
|
- An image a message names is copied into the child. One whose file is
|
|
1016
1017
|
gone is dropped, with `[image x.png was not copied]` in its message.
|
|
1018
|
+
- A child chi won't start raises `Error` with chi's reason: the session's
|
|
1019
|
+
model is gone from the config, or its toolkit is a config kit with an
|
|
1020
|
+
invalid `groups:` (`toolkit.kits`, `Toolkit.check_new!`).
|
|
1017
1021
|
- `review(subjects: [], seed: nil, recap_wait: 0)` starts a review session of
|
|
1018
1022
|
this one, as `chi review --from` does (see [Review sessions](sessions.md#review-sessions)):
|
|
1019
1023
|
a new session in this session's folder, on its model and setup, seeded with
|
data/docs/sessions.md
CHANGED
|
@@ -377,3 +377,18 @@ The `delegate` tool hands a task to a **child session**: an ordinary chi session
|
|
|
377
377
|
- **A child asks before it changes anything outside its folder** (the work tree top of the folder it started in): a write or edit there, mutating git in another checkout, a command that isn't read-only run from outside it or naming the main checkout or a sibling worktree. It's a core guardrail, on in every mode and without the guardrails bundle, and answered once or for the child's session ([Guardrails](guardrails.md#a-delegate-childs-boundary)); the approval relays to the parent's user as any other.
|
|
378
378
|
- Limits: a child cannot `delegate` (depth 1; the tool refuses), and one session may have at most `session.max_children` (default `4`, env `SAMAGOTCHI_SESSION_MAX_CHILDREN`) children running at a time, counted from the session files (a finished or idle-exited child does not count; one whose worker is still starting does, for 15 s). A refused call creates no session. Nothing stops children with the parent: `chi sessions stop ID` does.
|
|
379
379
|
- The link is the session's `parent_id`, written before the worker starts, so a respawn keeps it (and `delegate: true`, which a fork's `parent_id` doesn't come with; a child saved before that field is known by its `system/delegated` memory); `GET /api/sessions`, `/api/sessions/:id` and the web hub's `session` frames carry it and `delegate` (a fork is `false`; the web parent's children chip counts delegates only); `SessionManager.children_of` lists a parent's children.
|
|
380
|
+
## Restart fallback
|
|
381
|
+
|
|
382
|
+
If `chi sessions restart ID` cannot ask its worker (the Bridge is unreachable
|
|
383
|
+
or refuses its secret, the worker speaks a newer protocol, or its start is
|
|
384
|
+
stuck), chi sends TERM without writing the stop marker, waits up to
|
|
385
|
+
`SessionManager::FALLBACK_STOP_WAIT` (40 s: the worker writes its recap on
|
|
386
|
+
leave) for the owner lock, and resumes the session; a worker that doesn't let
|
|
387
|
+
go in time is reported as "didn't take the restart" and nothing is resumed. A
|
|
388
|
+
worker that took the lock meanwhile (the old one handed over after all) is
|
|
389
|
+
not touched. It refuses first if disk shows a running turn, pending
|
|
390
|
+
question or approval, queued input, or a running background task started by
|
|
391
|
+
the session; the message directs the user to `chi sessions stop ID`, then any prompt.
|
|
392
|
+
Reminders, a running `/btw`, and other in-memory worker state cannot be
|
|
393
|
+
checked and may be lost in this fallback. The web restart route behaves the
|
|
394
|
+
same way.
|
data/docs/testing.md
CHANGED
|
@@ -163,9 +163,44 @@ A session's Bridge answers only a request that names its secret (`X-Chi-Bridge`,
|
|
|
163
163
|
see [architecture.md](architecture.md)). A spec that writes its own request to a real Bridge (`Net::HTTP`, a raw
|
|
164
164
|
socket) names it too: `include BridgeAuth` (spec/support/bridge_auth.rb, required by the spec that needs it) gives
|
|
165
165
|
`bridge_auth(session_dir_or_sidecar)`, the header as a Hash, and `bridge_auth_line(...)` for a raw request; or read
|
|
166
|
-
`port` and `secret` from the sidecar in one go, as `
|
|
166
|
+
`port` and `secret` from the sidecar in one go, as `WorkerLink.client` does. A client made with `BridgeClient.new`
|
|
167
167
|
takes `secret:` (nil for a fake Bridge that doesn't check it).
|
|
168
168
|
|
|
169
|
+
### Support files
|
|
170
|
+
|
|
171
|
+
`spec/spec_helper.rb` loads the suite-wide helpers (marked *helper*: every example group gets them); a spec that needs
|
|
172
|
+
any other file requires it, so a spec file run alone still finds its requires. Each file's header says more.
|
|
173
|
+
|
|
174
|
+
| File | What it gives |
|
|
175
|
+
|---|---|
|
|
176
|
+
| `bounded_capture.rb` | `BoundedCapture.capture3`: `Open3.capture3` with a deadline, in its own process group killed on expiry |
|
|
177
|
+
| `bridge_auth.rb` | `BridgeAuth`: the Bridge secret's header for a hand-written request (above) |
|
|
178
|
+
| `chi_cli.rb` | *helper*: `run_chi` (bin/chi in a child process), `fork_chi` (in a fork: no Ruby boot), `isolated_chi_env` |
|
|
179
|
+
| `effective_model_configs.rb` | `EffectiveModelConfigs`: one config.yml holding every host the effective-model characterization pins, and its goldens |
|
|
180
|
+
| `env.rb` | *helper*: `with_env` sets ENV for a block and puts it back (nil unsets) |
|
|
181
|
+
| `failing_saves.rb` | shared context "failing session saves": `Session#save` raising ENOSPC while `saves_fail`, and the worker's `save_failed` records |
|
|
182
|
+
| `fake_chat_adapter.rb` | `FakeChatAdapter`: an `LLM::OpenAIChat` stand-in answering `#chat` with queued steps, recording each request |
|
|
183
|
+
| `fake_executables.rb` | `FakeExecutables`: fake commands (`gh`, `git`) for a spec's PATH, symlinks to one launcher (macOS's first-exec check is paid once) |
|
|
184
|
+
| `fake_provider_server.rb` | `FakeProviderServer`: an in-process HTTP provider replaying spec/fixtures/providers, recording requests |
|
|
185
|
+
| `fake_tailscale.rb` | A fake `tailscale` for `Web::Tunnel::Tailscale` (status, serve, lock from JSON files; argv.log) |
|
|
186
|
+
| `git_repo.rb` | *helper*: `git_repo`, a temp folder with `git init`, removed after the example |
|
|
187
|
+
| `integration_server.rb` | *helper*: the live model server `:integration` examples use, from `SAMAGOTCHI_INTEGRATION_*` ([Integration specs](#integration-specs)) |
|
|
188
|
+
| `mcp_bundle.rb` | The shipped mcp bundle's path and the fake MCP server its specs start (`MCP_FAKE_COMMAND`, no RubyGems) |
|
|
189
|
+
| `no_console.rb` | Loaded first (`ruby -r`) by spawned bin/chi children: `IO.console` is nil, so output never wraps at a terminal's width |
|
|
190
|
+
| `recording_surface.rb` | `RecordingSurface`: a `TerminalUI::Surface` that records commits and slots instead of drawing |
|
|
191
|
+
| `remote_backend.rb` | `RemoteBackend`: `SessionAccess::Remote` against chi web's Rack app in this process (no socket, LAN token gate) |
|
|
192
|
+
| `reply_wait_looks.rb` | `ReplyWaitLooks`: counts a `ReplyWait`'s polls, so a step waits for the looks instead of a delay |
|
|
193
|
+
| `stamps.rb` | *helper*: `next_stamp`, distinct ordered times for the lib's `now:` seams; the pinned history clock |
|
|
194
|
+
| `task_dirs.rb` | `with_task_dir`: a tasks' folder of its own, every task stopped and its recorders joined after |
|
|
195
|
+
| `test_kernel.rb` | `TestKernel`: `test_kernel` (a real `KernelLoop` to stub `#run` on) and `test_client` for Engine specs |
|
|
196
|
+
| `thinking_off.rb` | shared context "thinking off": `SAMAGOTCHI_THINKING_LEVEL=off` for the example |
|
|
197
|
+
| `virtual_terminal.rb` | `VirtualTerminal`: just enough VT100 to check what a Screen leaves on the terminal |
|
|
198
|
+
| `waiting.rb` | *helper*: `SpecWaiting` (`wait_for`, `wait_until`, `join!`, `pop!`, …), the test kit's helpers ([Waiting and time](#waiting-and-time)) |
|
|
199
|
+
| `web_push_receiver.rb` | `WebPushReceiver`: a device's key pair and auth secret, decrypting what chi web pushed (RFC 8291) |
|
|
200
|
+
| `web_server.rb` | `WebServer`: chi web's `Web::App` served for real on a free port in a thread, for specs a Rack mock can't serve |
|
|
201
|
+
| `worker_links.rb` | `WorkerLinks`: `live_link(sidecar)` / `none_link`, the `WorkerLink` outcomes a stub of `WorkerLink.resolve` / `.await` (or chi web's `#worker_link`) returns |
|
|
202
|
+
| `worker_loop_rounds.rb` | `WorkerLoopRounds`: counts a worker's idle loop passes, for "nothing happens" checks |
|
|
203
|
+
|
|
169
204
|
## Bundle specs: the test kit
|
|
170
205
|
|
|
171
206
|
A shipped bundle's plugin and hook files are tested through the bundle test kit (`samagotchi/testing`,
|
|
@@ -357,3 +392,8 @@ kept with the build id `/status` shows (`identity.cache.build_id`); when that ch
|
|
|
357
392
|
the version and record it again. This step goes once a Splash release carries its version in `/status`.
|
|
358
393
|
|
|
359
394
|
The code is `script/deps/deps_check.rb`; its spec stubs every request and command (`spec/deps_check_spec.rb`).
|
|
395
|
+
|
|
396
|
+
`script/dead-code` (a tech-debt audit round runs it too) lists the methods defined under lib/ that nothing in the repo
|
|
397
|
+
names (Prism; `be_foo`/`have_foo` count for `foo?`/`has_foo?`), as `path:line name [notes]` with a count; a note says
|
|
398
|
+
what may explain a row (a doc, a name built at run time, a framework hook). Advisory: it always exits 0, CI doesn't
|
|
399
|
+
run it. Its spec is `spec/script/dead_code_spec.rb`.
|
|
@@ -196,7 +196,7 @@ module Samagotchi
|
|
|
196
196
|
end
|
|
197
197
|
return failure(worker_gone(id)) unless reply
|
|
198
198
|
|
|
199
|
-
return failure(BridgeSecret
|
|
199
|
+
return failure(BridgeSecret.refused(id, via: @access.invocation)) if reply.secret_refused?
|
|
200
200
|
|
|
201
201
|
case reply.status
|
|
202
202
|
when 200 then nil
|
data/lib/samagotchi/bridge.rb
CHANGED
|
@@ -32,6 +32,7 @@ require_relative "log"
|
|
|
32
32
|
require_relative "proxy_trace"
|
|
33
33
|
require_relative "version"
|
|
34
34
|
require_relative "worker_sidecar"
|
|
35
|
+
require_relative "worker_link"
|
|
35
36
|
require_relative "relay_verifier"
|
|
36
37
|
require_relative "relay_watcher"
|
|
37
38
|
require_relative "reply_wait"
|
|
@@ -75,9 +76,6 @@ module Samagotchi
|
|
|
75
76
|
SECRET_HEADER = BridgeSecret::HEADER.downcase
|
|
76
77
|
# Bytes of randomness in a Bridge's secret (hex in bridge.json: twice as many characters).
|
|
77
78
|
SECRET_BYTES = 32
|
|
78
|
-
# What a request without this Bridge's secret, or with another one, is told (403 bridge_secret).
|
|
79
|
-
SECRET_REFUSED = BridgeSecret::REFUSED
|
|
80
|
-
|
|
81
79
|
# @param engine [Samagotchi::Engine] the owning engine (must already live
|
|
82
80
|
# in this process)
|
|
83
81
|
# @param state_dir [String] the session state directory root
|
|
@@ -484,7 +482,7 @@ module Samagotchi
|
|
|
484
482
|
break
|
|
485
483
|
elsif !secret_ok?(headers)
|
|
486
484
|
note_refused(:secret, "secret_refused", request[:path])
|
|
487
|
-
write_json(io, 403, nil, { error: BridgeSecret::ERROR, detail:
|
|
485
|
+
write_json(io, 403, nil, { error: BridgeSecret::ERROR, detail: BridgeSecret.refused(@session_id) })
|
|
488
486
|
break
|
|
489
487
|
# From here on a client of this worker (it named the secret): only its
|
|
490
488
|
# requests keep the worker from its idle exit, never a refused one's
|
|
@@ -1500,17 +1498,19 @@ module Samagotchi
|
|
|
1500
1498
|
# secret: what every request must name (#secret_ok?).
|
|
1501
1499
|
WorkerSidecar.new(port: @port, bind: @bind, session_id: @session_id, started_at: Time.now.iso8601(3),
|
|
1502
1500
|
version: Samagotchi::VERSION, input_format: @input_format, features: FEATURES,
|
|
1503
|
-
epoch: @epoch, secret: @secret
|
|
1501
|
+
epoch: @epoch, secret: @secret, pid: Process.pid,
|
|
1502
|
+
protocol: WorkerLink::PROTOCOL).write(session_dir)
|
|
1504
1503
|
rescue StandardError => e
|
|
1505
1504
|
Log.warn(:bridge, "sidecar_write_failed", echo: "Bridge: failed to write #{WorkerSidecar::FILE}: #{e.class}: #{e.message}", error: e.class.name)
|
|
1506
1505
|
end
|
|
1507
1506
|
|
|
1508
|
-
# Only while it still
|
|
1509
|
-
# session may have written its own since
|
|
1507
|
+
# Only while it still carries this bridge's epoch: a racing worker for the
|
|
1508
|
+
# same session may have written its own since (a file without an epoch
|
|
1509
|
+
# is only ever an older worker's).
|
|
1510
1510
|
def remove_sidecar
|
|
1511
1511
|
return unless @server
|
|
1512
1512
|
|
|
1513
|
-
File.unlink(WorkerSidecar.path(session_dir)) if WorkerSidecar.read(session_dir)&.
|
|
1513
|
+
File.unlink(WorkerSidecar.path(session_dir)) if WorkerSidecar.read(session_dir)&.epoch == @epoch.to_s
|
|
1514
1514
|
rescue StandardError
|
|
1515
1515
|
nil
|
|
1516
1516
|
end
|
|
@@ -158,16 +158,18 @@ module Samagotchi
|
|
|
158
158
|
|
|
159
159
|
# One connection: :events when it yielded any, else why it failed.
|
|
160
160
|
def read_connection
|
|
161
|
-
sock
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
sock.close rescue nil
|
|
166
|
-
return :closed
|
|
161
|
+
sock = nil
|
|
162
|
+
outcome = @client.open_stream(query: @query, last_event_id: @last_event_id, header_timeout: HEADER_TIMEOUT,
|
|
163
|
+
attempts: 1) do |opened_sock|
|
|
164
|
+
sock = opened_sock if attach(opened_sock)
|
|
167
165
|
end
|
|
168
|
-
return :
|
|
169
|
-
return :
|
|
170
|
-
return :
|
|
166
|
+
return :closed if closed?
|
|
167
|
+
return :unreachable if outcome.is_a?(StreamOpen::Unreachable)
|
|
168
|
+
return :unknown_session if outcome.is_a?(StreamOpen::Refused) && outcome.reason == :unknown_session
|
|
169
|
+
return :secret_refused if outcome.is_a?(StreamOpen::Refused) && outcome.reason == :bridge_secret
|
|
170
|
+
return :unreachable unless outcome.is_a?(StreamOpen::Opened)
|
|
171
|
+
|
|
172
|
+
sock = outcome.sock
|
|
171
173
|
|
|
172
174
|
# Back as soon as the stream answers: a replay may bring nothing
|
|
173
175
|
# (the session was quiet), and the next frame could be minutes away.
|
|
@@ -194,24 +196,6 @@ module Samagotchi
|
|
|
194
196
|
detach(sock)
|
|
195
197
|
end
|
|
196
198
|
|
|
197
|
-
# A refusal's JSON body: its Content-Length's worth, else up to the
|
|
198
|
-
# Bridge's close, within HEADER_TIMEOUT.
|
|
199
|
-
def refusal_body(sock, headers)
|
|
200
|
-
body = +""
|
|
201
|
-
length = headers.to_h["content-length"]&.to_i
|
|
202
|
-
limit = length&.between?(0, 16_384) ? length : 16_384
|
|
203
|
-
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + HEADER_TIMEOUT
|
|
204
|
-
while body.bytesize < limit
|
|
205
|
-
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
206
|
-
break unless left.positive? && sock.wait_readable(left)
|
|
207
|
-
|
|
208
|
-
body << sock.readpartial(limit - body.bytesize)
|
|
209
|
-
end
|
|
210
|
-
body
|
|
211
|
-
rescue IOError, SystemCallError
|
|
212
|
-
body
|
|
213
|
-
end
|
|
214
|
-
|
|
215
199
|
# Publish the socket for #close; false when already closed.
|
|
216
200
|
def attach(sock)
|
|
217
201
|
@mutex.synchronize do
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "../bridge_secret"
|
|
5
|
+
|
|
6
|
+
module Samagotchi
|
|
7
|
+
class BridgeClient
|
|
8
|
+
# The result of opening an SSE connection. Only Opened owns a socket the
|
|
9
|
+
# caller may read; refused sockets are consumed for their detail and closed.
|
|
10
|
+
module StreamOpen
|
|
11
|
+
Opened = Data.define(:sock, :status, :headers)
|
|
12
|
+
Refused = Data.define(:reason, :detail, :status)
|
|
13
|
+
Unreachable = Data.define(:attempts)
|
|
14
|
+
|
|
15
|
+
BODY_LIMIT = 16_384
|
|
16
|
+
BODY_TIMEOUT = 5.0
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# The one place a stream's response status is interpreted. Through chi
|
|
21
|
+
# web, its mapped bridge-secret and not-live responses have their own
|
|
22
|
+
# status codes (502 and 503).
|
|
23
|
+
def classify(sock, status, headers)
|
|
24
|
+
return Unreachable.new(attempts: 0) unless sock
|
|
25
|
+
return Opened.new(sock: sock, status: 200, headers: headers) if status.to_i == 200
|
|
26
|
+
return Unreachable.new(attempts: 1) if status.to_i == 503
|
|
27
|
+
|
|
28
|
+
body = refusal_body(sock, headers)
|
|
29
|
+
json = parse(body)
|
|
30
|
+
detail = json&.[]("detail") || (body unless body.empty?)
|
|
31
|
+
code = status.to_i
|
|
32
|
+
error = json&.[]("error")
|
|
33
|
+
outcome = if code == 404
|
|
34
|
+
Refused.new(reason: :unknown_session, detail: detail, status: code)
|
|
35
|
+
elsif BridgeSecret.refused?(code, json || body)
|
|
36
|
+
Refused.new(reason: :bridge_secret, detail: detail, status: code)
|
|
37
|
+
elsif code == 403 && error == "proxied"
|
|
38
|
+
Refused.new(reason: :proxied, detail: detail, status: code)
|
|
39
|
+
elsif code == 403 && error == "cross_origin"
|
|
40
|
+
Refused.new(reason: :cross_origin, detail: detail, status: code)
|
|
41
|
+
elsif code == 502 && error == BridgeSecret::ERROR
|
|
42
|
+
Refused.new(reason: :bridge_secret, detail: detail, status: code)
|
|
43
|
+
else
|
|
44
|
+
Refused.new(reason: :other, detail: detail, status: code)
|
|
45
|
+
end
|
|
46
|
+
outcome
|
|
47
|
+
ensure
|
|
48
|
+
sock&.close unless (status || 0).to_i == 200
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# A refusal body is bounded and interruptible by closing its socket.
|
|
52
|
+
# Content-Length replies stop at their declared size; older Bridges
|
|
53
|
+
# without it are read through EOF.
|
|
54
|
+
def refusal_body(sock, headers)
|
|
55
|
+
length = headers.to_h["content-length"]&.to_i
|
|
56
|
+
limit = length&.between?(0, BODY_LIMIT) ? length : BODY_LIMIT
|
|
57
|
+
body = +""
|
|
58
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + BODY_TIMEOUT
|
|
59
|
+
while body.bytesize < limit
|
|
60
|
+
left = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
61
|
+
break unless left.positive? && sock.wait_readable(left)
|
|
62
|
+
|
|
63
|
+
body << sock.readpartial(limit - body.bytesize)
|
|
64
|
+
end
|
|
65
|
+
body.force_encoding(Encoding::UTF_8).scrub
|
|
66
|
+
rescue IOError, SystemCallError
|
|
67
|
+
body.force_encoding(Encoding::UTF_8).scrub
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def parse(body)
|
|
71
|
+
json = JSON.parse(body)
|
|
72
|
+
json if json.is_a?(Hash)
|
|
73
|
+
rescue JSON::ParserError
|
|
74
|
+
nil
|
|
75
|
+
end
|
|
76
|
+
private_class_method :parse
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -32,8 +32,14 @@ module Samagotchi
|
|
|
32
32
|
# @return [Array(TCPSocket, Integer, Hash), nil] the socket positioned
|
|
33
33
|
# at the body, the HTTP status and the response headers (names in
|
|
34
34
|
# lower case), or nil when the server can't be reached
|
|
35
|
+
# @raise [Errno::ETIMEDOUT] a connected server didn't finish its headers
|
|
35
36
|
def open(host:, port:, path:, last_event_id: nil, headers: {}, timeout: nil)
|
|
36
|
-
|
|
37
|
+
begin
|
|
38
|
+
sock = TCPSocket.new(host, port, connect_timeout: timeout || CONNECT_TIMEOUT)
|
|
39
|
+
rescue SystemCallError, SocketError, IOError
|
|
40
|
+
return nil
|
|
41
|
+
end
|
|
42
|
+
|
|
37
43
|
sock.write(request(host: host, port: port, path: path, last_event_id: last_event_id, headers: headers))
|
|
38
44
|
deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
|
|
39
45
|
status = header_line(sock, deadline).to_s[STATUS_LINE, 1].to_i
|
|
@@ -47,7 +53,7 @@ module Samagotchi
|
|
|
47
53
|
[sock, status, received]
|
|
48
54
|
rescue SystemCallError, SocketError, IOError
|
|
49
55
|
sock&.close rescue nil
|
|
50
|
-
|
|
56
|
+
raise
|
|
51
57
|
end
|
|
52
58
|
|
|
53
59
|
# The request's bytes.
|
|
@@ -8,6 +8,7 @@ require_relative "worker_sidecar"
|
|
|
8
8
|
require_relative "bridge_secret"
|
|
9
9
|
require_relative "delivery"
|
|
10
10
|
require_relative "chi_invocation"
|
|
11
|
+
require_relative "bridge_client/stream_open"
|
|
11
12
|
require_relative "bridge_client/sse_parser"
|
|
12
13
|
require_relative "bridge_client/event_stream"
|
|
13
14
|
require_relative "bridge_client/stream_request"
|
|
@@ -15,8 +16,8 @@ require_relative "bridge_client/stream_request"
|
|
|
15
16
|
module Samagotchi
|
|
16
17
|
# Client side of a session worker's Bridge: the 127.0.0.1 HTTP + SSE server
|
|
17
18
|
# each SessionManager worker runs for its Engine (see Bridge). Discovery goes
|
|
18
|
-
# through the worker's `bridge.json` sidecar
|
|
19
|
-
# directory.
|
|
19
|
+
# through WorkerLink (the worker's `bridge.json` sidecar, WorkerSidecar, in
|
|
20
|
+
# the session directory); .for_sidecar makes the client.
|
|
20
21
|
#
|
|
21
22
|
# Requests are raw one-shot HTTP/1.1 over a TCPSocket (`Connection: close`),
|
|
22
23
|
# exactly what the Bridge's minimal server expects.
|
|
@@ -27,7 +28,7 @@ module Samagotchi
|
|
|
27
28
|
# The request header that carries the Bridge's secret (Bridge#secret_ok?).
|
|
28
29
|
SECRET_HEADER = BridgeSecret::HEADER
|
|
29
30
|
STREAM_CONNECT_ATTEMPTS = 3
|
|
30
|
-
# Seconds #
|
|
31
|
+
# Seconds #open_stream waits for the Bridge's response headers:
|
|
31
32
|
# a worker that took the connection and froze must not hold the web's
|
|
32
33
|
# proxy thread (as EventStream::HEADER_TIMEOUT for the TUI's).
|
|
33
34
|
STREAM_HEADER_TIMEOUT = 5.0
|
|
@@ -49,7 +50,7 @@ module Samagotchi
|
|
|
49
50
|
Response = Struct.new(:status, :body, keyword_init: true) do
|
|
50
51
|
def ok? = status == 200
|
|
51
52
|
|
|
52
|
-
# The Bridge refused it for want of its secret (BridgeSecret
|
|
53
|
+
# The Bridge refused it for want of its secret (BridgeSecret.refused says what to do).
|
|
53
54
|
def secret_refused? = BridgeSecret.refused?(status, body)
|
|
54
55
|
|
|
55
56
|
# @return [Hash, nil] the body parsed as JSON, or nil when it is not
|
|
@@ -75,14 +76,6 @@ module Samagotchi
|
|
|
75
76
|
"this session's worker runs an older chi and can't #{cant}; restart it: #{restart} (its turns still work)"
|
|
76
77
|
end
|
|
77
78
|
|
|
78
|
-
# The live Bridge's sidecar in +session_dir+, or nil. A sidecar whose
|
|
79
|
-
# port refuses a quick connect is stale (its worker died) and is removed.
|
|
80
|
-
# @param session_dir [String]
|
|
81
|
-
# @return [WorkerSidecar, nil]
|
|
82
|
-
def self.live_sidecar(session_dir, host: HOST)
|
|
83
|
-
WorkerSidecar.live(session_dir, unlink: true, host: host)
|
|
84
|
-
end
|
|
85
|
-
|
|
86
79
|
# The one way a client is made for a worker: its port and its secret
|
|
87
80
|
# come from the same sidecar read, so a respawned worker (a new port, a
|
|
88
81
|
# new secret) is reached by a client made after it, never by hand.
|
|
@@ -94,24 +87,11 @@ module Samagotchi
|
|
|
94
87
|
new(session_id: session_id, port: sidecar.port, secret: sidecar.secret, host: host, read_timeout: read_timeout)
|
|
95
88
|
end
|
|
96
89
|
|
|
97
|
-
# @return [BridgeClient, nil] a client for the live Bridge in +session_dir+
|
|
98
|
-
def self.discover(session_id, session_dir:, host: HOST, read_timeout: READ_TIMEOUT)
|
|
99
|
-
for_sidecar(session_id, live_sidecar(session_dir, host: host), host: host, read_timeout: read_timeout)
|
|
100
|
-
end
|
|
101
|
-
|
|
102
90
|
# How often a wait for a new worker's Bridge looks: a missing sidecar is
|
|
103
91
|
# one stat, and a 0.1 s step cost a create up to 0.1 s (the web's
|
|
104
92
|
# create landed on 0.53/0.63/0.73 s).
|
|
105
93
|
SPAWN_POLL_INTERVAL = 0.02
|
|
106
94
|
|
|
107
|
-
# Wait for a freshly spawned worker to publish its Bridge.
|
|
108
|
-
# @param timeout [Float] seconds
|
|
109
|
-
# @return [BridgeClient, nil] nil when no live sidecar appeared in time
|
|
110
|
-
def self.wait_for(session_id, session_dir:, timeout:, host: HOST)
|
|
111
|
-
sidecar = poll(timeout, interval: SPAWN_POLL_INTERVAL) { live_sidecar(session_dir, host: host) }
|
|
112
|
-
for_sidecar(session_id, sidecar, host: host)
|
|
113
|
-
end
|
|
114
|
-
|
|
115
95
|
# Call the block every +interval+ seconds until it returns a truthy value
|
|
116
96
|
# or +timeout+ seconds have passed (it is always called at least once).
|
|
117
97
|
# @return [Object, nil] the block's first truthy value
|
|
@@ -314,22 +294,26 @@ module Samagotchi
|
|
|
314
294
|
# @return [Array(Integer, Object)] the status and the parsed JSON body
|
|
315
295
|
# (nil when it isn't JSON); [nil, nil] when no reply came
|
|
316
296
|
def get(path)
|
|
297
|
+
response = get_response(path)
|
|
298
|
+
[response.status, response.json]
|
|
299
|
+
rescue StandardError
|
|
300
|
+
[nil, nil]
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# GET /session/:id/<path>, preserving the raw response and raising on I/O.
|
|
304
|
+
# @return [Response]
|
|
305
|
+
def get_response(path)
|
|
317
306
|
sock = TCPSocket.new(@host, @port)
|
|
318
307
|
sock.write("GET /session/#{@session_id}/#{path} HTTP/1.1\r\nHost: #{@host}:#{@port}\r\n#{secret_line}Connection: close\r\n\r\n")
|
|
319
308
|
response = read_reply(sock, path)
|
|
320
|
-
sock.close rescue nil
|
|
321
309
|
# A 500 whose text has "200" in it is not a 200.
|
|
322
310
|
status = response && response[STATUS_LINE, 1]
|
|
323
|
-
|
|
311
|
+
raise IOError, "bridge #{path}: invalid response status" unless status
|
|
324
312
|
|
|
325
313
|
body = response.split("\r\n\r\n", 2)[1] || ""
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
rescue
|
|
329
|
-
nil
|
|
330
|
-
end]
|
|
331
|
-
rescue StandardError
|
|
332
|
-
[nil, nil]
|
|
314
|
+
Response.new(status: status.to_i, body: body)
|
|
315
|
+
ensure
|
|
316
|
+
sock&.close rescue nil
|
|
333
317
|
end
|
|
334
318
|
|
|
335
319
|
# Monotonic SSE cursor of the live Engine, from GET /session/:id/state.
|
|
@@ -342,46 +326,49 @@ module Samagotchi
|
|
|
342
326
|
nil
|
|
343
327
|
end
|
|
344
328
|
|
|
345
|
-
# GET /session/:id/stream
|
|
346
|
-
#
|
|
347
|
-
# @param query [String] "" or "?from_seq=N"
|
|
348
|
-
# @param last_event_id [String, nil] reconnect cursor; the Bridge prefers it
|
|
349
|
-
# over ?from_seq
|
|
350
|
-
# @param running [#call, nil] asked before every read, and every
|
|
351
|
-
# STREAM_POLL seconds of silence: the stream ends once it returns
|
|
352
|
-
# false. A quiet stream is never cut otherwise.
|
|
329
|
+
# Open GET /session/:id/stream and classify the response before the
|
|
330
|
+
# caller answers its own request.
|
|
353
331
|
# @param header_timeout [Float] see STREAM_HEADER_TIMEOUT
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
sock.close rescue nil
|
|
374
|
-
return
|
|
332
|
+
# @param attempts [Integer] retries for connection failures only
|
|
333
|
+
# @yield [sock] called after response headers arrive, before classifying a
|
|
334
|
+
# refusal; EventStream uses it to publish the socket for #close
|
|
335
|
+
# @return [StreamOpen::Opened, StreamOpen::Refused, StreamOpen::Unreachable]
|
|
336
|
+
def open_stream(query: "", last_event_id: nil, header_timeout: STREAM_HEADER_TIMEOUT,
|
|
337
|
+
attempts: STREAM_CONNECT_ATTEMPTS)
|
|
338
|
+
count = 0
|
|
339
|
+
while count < attempts
|
|
340
|
+
count += 1
|
|
341
|
+
connection = connect_stream(query: query, last_event_id: last_event_id, timeout: header_timeout)
|
|
342
|
+
if connection
|
|
343
|
+
sock, status, headers = connection
|
|
344
|
+
unless !block_given? || yield(sock)
|
|
345
|
+
sock.close rescue nil
|
|
346
|
+
return StreamOpen::Unreachable.new(attempts: count)
|
|
347
|
+
end
|
|
348
|
+
return StreamOpen.classify(sock, status, headers)
|
|
349
|
+
end
|
|
350
|
+
sleep(0.15 * count) if count < attempts
|
|
375
351
|
end
|
|
352
|
+
StreamOpen::Unreachable.new(attempts: count)
|
|
353
|
+
rescue Errno::ETIMEDOUT
|
|
354
|
+
StreamOpen::Unreachable.new(attempts: [count, 1].max)
|
|
355
|
+
rescue SystemCallError, SocketError, IOError
|
|
356
|
+
StreamOpen::Unreachable.new(attempts: [count, 1].max)
|
|
357
|
+
end
|
|
376
358
|
|
|
359
|
+
# Read a successful stream opened by #open_stream.
|
|
360
|
+
# @param opened [StreamOpen::Opened]
|
|
361
|
+
# @param running [#call, nil] checked before every read and every
|
|
362
|
+
# STREAM_POLL seconds of silence
|
|
363
|
+
def read_stream(opened, running: nil)
|
|
364
|
+
sock = opened.sock
|
|
377
365
|
begin
|
|
378
366
|
loop do
|
|
379
367
|
if running
|
|
380
368
|
break unless running.call
|
|
381
369
|
next unless sock.wait_readable(STREAM_POLL)
|
|
382
370
|
end
|
|
383
|
-
|
|
384
|
-
yield chunk
|
|
371
|
+
yield sock.readpartial(4096)
|
|
385
372
|
rescue IOError, Errno::ECONNRESET, Errno::ECONNREFUSED # EOFError is an IOError
|
|
386
373
|
break
|
|
387
374
|
end
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
+
require_relative "chi_invocation"
|
|
4
5
|
|
|
5
6
|
module Samagotchi
|
|
6
7
|
# A session Bridge's secret, as both sides name it: the request header
|
|
@@ -12,16 +13,17 @@ module Samagotchi
|
|
|
12
13
|
HEADER = "X-Chi-Bridge"
|
|
13
14
|
# The 403's error.
|
|
14
15
|
ERROR = "bridge_secret"
|
|
15
|
-
# What a request without this Bridge's secret, or with another one, is
|
|
16
|
-
# told, and what a client shows when it gets that: the asker comes from
|
|
17
|
-
# a chi that reads no secret, a chi web, an attached terminal or a
|
|
18
|
-
# session worker (a delegate's parent) started before chi was updated.
|
|
19
|
-
REFUSED = "the request didn't name this session worker's secret: a chi web, an attached terminal or a session " \
|
|
20
|
-
"worker started before chi was updated can't reach it; restart chi web, re-attach terminals " \
|
|
21
|
-
"(chi --attach ID) and restart the older session workers (chi sessions restart ID...)"
|
|
22
16
|
|
|
23
17
|
module_function
|
|
24
18
|
|
|
19
|
+
# What every client shows when a worker refuses its secret.
|
|
20
|
+
def refused(session_id, via: ChiInvocation::HERE)
|
|
21
|
+
web = via.remote? ? via.web : "chi web"
|
|
22
|
+
"session #{session_id}'s worker refused this chi's request (it didn't name its secret): a chi web or " \
|
|
23
|
+
"terminal started before chi was updated can't reach it; restart #{web}, re-attach with " \
|
|
24
|
+
"#{via.attach(session_id)}, or restart the worker with #{via.command("sessions restart #{session_id}")}"
|
|
25
|
+
end
|
|
26
|
+
|
|
25
27
|
# Whether a Bridge reply (+status+, raw +body+) is this refusal.
|
|
26
28
|
def refused?(status, body)
|
|
27
29
|
return false unless status.to_i == 403
|