M4a tasks 14 and 15: glue written, calls tabled, after task 14's session wrote nothing

Task 14's session read the crate to learn the APIs and planned `connect` in prose until it was
cut off. `connect`, `Gateway::new` and `catch_up` are now written; the task lists every call with
its signature and makes the copy and the failing test the first actions. Task 15's comment is
rewritten one step per item. Both checked to fail, then pass when filled from their comments.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-23 21:50:18 -07:00
co-authored by Claude Opus 5.5
parent d1d8531495
commit 564875f0a0
6 changed files with 203 additions and 64 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ How it is used:
| T22 | List the fail-closed states a task creates, with their runbook anchors, in the task itself. A script can check that a pointer names an existing entry; nothing can check for a pointer that was never written. | M3a finding 3: four startup failures in `serve` and `main` print no pointer, and the spec's own list of pointers omitted them. | | T22 | List the fail-closed states a task creates, with their runbook anchors, in the task itself. A script can check that a pointer names an existing entry; nothing can check for a pointer that was never written. | M3a finding 3: four startup failures in `serve` and `main` print no pointer, and the spec's own list of pointers omitted them. |
| T23 | A test that writes a script and then runs it must hold a lock shared by every test in its binary that starts a process. Otherwise another test's fork can hold the new script open for writing at that moment, and running it fails with "Text file busy" (ETXTBSY), about once in seven runs. Give the lock in the support file and say why. | M3b plan checks: the fake `curl` tests failed 8 times in 40 until every forking test took `serial()`; then 0 in 60. | | T23 | A test that writes a script and then runs it must hold a lock shared by every test in its binary that starts a process. Otherwise another test's fork can hold the new script open for writing at that moment, and running it fails with "Text file busy" (ETXTBSY), about once in seven runs. Give the lock in the support file and say why. | M3b plan checks: the fake `curl` tests failed 8 times in 40 until every forking test took `serial()`; then 0 in 60. |
| T24 | A task that changes a `Cargo.toml` must stage `Cargo.lock` in its `git add` line. Better, put `Cargo.lock` in every task's `git add`; it is a no-op when unchanged. | M3b task 04: committed correctly, left the lock behind, and the driver stopped on an unclean tree. | | T24 | A task that changes a `Cargo.toml` must stage `Cargo.lock` in its `git add` line. Better, put `Cargo.lock` in every task's `git add`; it is a no-op when unchanged. | M3b task 04: committed correctly, left the lock behind, and the driver stopped on an unclean tree. |
| T25 | Size a task by the largest function the model must hold in one turn, not by the task. Ornith writes one function with a few branches well; a function with half a dozen branches and threads (M3b's `run`) it plans in its head until the turn runs out, with nothing written. Give such a task a compiling skeleton with the big function already written as glue over small `todo!()` helpers, and say to fill one at a time with `cargo check` between. | M3b task 11: four sessions. Two wrote nothing; a whole-file skeleton got five of six functions; the finer skeleton finished it in ten minutes, and tasks 12 and 13 followed without a stop. M4a task 08: again, a skeleton whose `header` was one `todo!()` with a dozen branches; nothing written. Size every `todo!()`, not only the task: the largest comment above a `todo!()` is a fair proxy, and one over about six lines needs splitting. | | T25 | Size a task by the largest function the model must hold in one turn, not by the task. Ornith writes one function with a few branches well; a function with half a dozen branches and threads (M3b's `run`) it plans in its head until the turn runs out, with nothing written. Give such a task a compiling skeleton with the big function already written as glue over small `todo!()` helpers, and say to fill one at a time with `cargo check` between. | M3b task 11: four sessions. Two wrote nothing; a whole-file skeleton got five of six functions; the finer skeleton finished it in ten minutes, and tasks 12 and 13 followed without a stop. M4a task 08: again, a skeleton whose `header` was one `todo!()` with a dozen branches; nothing written. Size every `todo!()`, not only the task: the largest comment above a `todo!()` is a fair proxy, and one over about six lines needs splitting. M4a task 14: a task with a dozen small `todo!()`s still stalled, because the model read the whole crate to learn the APIs first; give a table of every call the task makes, with its signature, and make copying and the failing test the first two actions. |
| T26 | Replay each task's end state on its own, **and** read the task file against the reference for anything the reference has that the task does not ask for. The replay proves the tests can pass; only the reading finds a field the reference reads through a getter the task never mentions. | M3b task 11: the task stored `egress_dir` without the reference's getter, so the field was never read and clippy failed; the replay passed because the reference had the getter. | | T26 | Replay each task's end state on its own, **and** read the task file against the reference for anything the reference has that the task does not ask for. The replay proves the tests can pass; only the reading finds a field the reference reads through a getter the task never mentions. | M3b task 11: the task stored `egress_dir` without the reference's getter, so the field was never read and clippy failed; the replay passed because the reference had the getter. |
| T27 | Every wait in a given test has a limit (`recv_timeout`, a deadline loop, `is_finished` before `join`). A test that waits forever on a broken implementation hangs the driver instead of failing, and the implementer cannot tell a hang from slowness. | M4a planning: breaking the reference's ping made `ws_conn` hang on `rx.recv()`; retrying a refused token made the serve test hang on `join()`. Both now fail within 5 s. | | T27 | Every wait in a given test has a limit (`recv_timeout`, a deadline loop, `is_finished` before `join`). A test that waits forever on a broken implementation hangs the driver instead of failing, and the implementer cannot tell a hang from slowness. | M4a planning: breaking the reference's ping made `ws_conn` hang on `rx.recv()`; retrying a refused token made the serve test hang on `join()`. Both now fail within 5 s. |
+50 -16
View File
@@ -33,21 +33,54 @@ The loop that ties tasks 03 to 13 together (spec sections 8 and 9):
skeletons `crates/gatewayd/src/serve/mod.rs` and `crates/gatewayd/src/serve/handle.rs` skeletons `crates/gatewayd/src/serve/mod.rs` and `crates/gatewayd/src/serve/handle.rs`
- Modify: `crates/gatewayd/src/lib.rs` (`pub mod serve;`), `docs/implementer-log.md` - Modify: `crates/gatewayd/src/lib.rs` (`pub mod serve;`), `docs/implementer-log.md`
## Before anything else
Your **first two actions** are steps 1 and 2 below: copy the files, and see the tests fail. Do not
read the other modules of `gatewayd` or `proto` first. Everything the functions you write call is
in the table below, with its signature, and the comment above each `todo!()` gives the code to
write, down to the expressions. Write each function as its comment says, run
`cargo check -p gatewayd`, and go on to the next. Do not weigh other ways to write it.
## The skeletons ## The skeletons
`serve/mod.rs`, written: the pointers, `INTERRUPTED`, `Log`, `Stop` (`Auth`, `State`, `Start`, `serve/mod.rs`, written: the pointers, `INTERRUPTED`, `Log`, `Stop` (`Auth`, `State`, `Start`,
`Asked`) and its `Display`, `Tuning` and its default, the `Gateway` struct, and the two functions `Asked`) and its `Display`, `Tuning` and its default, the `Gateway` struct, and the glue: **`run`**
that are glue: **`run`** (the connect-or-back-off loop) and **`Gateway::event_loop`** (finish turns, (the connect-or-back-off loop), **`connect`** (`users/me`, the WebSocket, the wait for `hello`),
send typing, wait for an event, handle it). Read both first: they call everything you write. **`Gateway::new`** and **`Gateway::event_loop`** (finish turns, send typing, wait for an event,
To fill: `From<StateError> for Stop`, `backoff`, `sleep_unless`, `connect`, `Gateway::new`, handle it). To fill: `From<StateError> for Stop`, `backoff`, `sleep_unless`, `Gateway::connected`.
`Gateway::connected`.
`serve/handle.rs`, all to fill: `now_ms`, `post`, `tracked`, `handle_post`, `start`, `finished`, `serve/handle.rs`, written: the glue **`catch_up`** (which channels). To fill: `now_ms`, `post`,
`typing`, `catch_up`. `tracked`, `finished`, `typing`, `start`, `handle_post`, `catch_up_channel`.
Each `todo!()` has its steps above it, with the exact log lines. `run` takes the token already Twelve functions, none more than about twenty lines. `run` takes the token already loaded and a
loaded and a `stop` flag, so the tests need no secrets and can end it; task 15's `main` passes a `stop` flag, so the tests need no secrets and can end it; task 15's `main` passes a flag that is
flag that is never set. never set.
## What the functions call
| Call | Signature (from the earlier tasks) |
|---|---|
| `self.client.create_post` | `(&self, channel: &str, root: &str, message: &str) -> Result<Post, MmError>` |
| `self.client.posts_since` | `(&self, channel: &str, since: i64) -> Result<Since, MmError>`; `Since { posts: Vec<Post>, full: bool }` |
| `self.state.seen` / `knows_thread` | `(&self, id: &str) -> bool` |
| `self.state.handled` | `(&mut self, post_id: &str, channel: &str, create_at: i64) -> Result<(), StateError>` |
| `self.state.since` | `(&self, channel: &str) -> Option<i64>` |
| `self.state.mark` | `(&mut self, channel: &str, at: i64) -> Result<(), StateError>` |
| `self.state.join_thread` | `(&mut self, root: &str) -> Result<(), StateError>` |
| `self.state.start_turn` | `(&mut self, turn: InFlight) -> Result<(), StateError>`; `InFlight { session, channel, root }`, all `String` |
| `self.state.end_turn` | `(&mut self, session: &str) -> Result<(), StateError>` |
| `self.state.take_in_flight` | `(&mut self) -> Result<Vec<InFlight>, StateError>` |
| `self.router.route` | `(&self, post: &Post, channel_type: &str, known: &dyn Fn(&str) -> bool) -> Route` |
| `Router::new` | `(me_id: &str, me_name: &str, users: &[String], channels: &[String]) -> Router` |
| `self.queues.push` | `(&mut self, message: Message) -> Pushed` |
| `self.queues.finish` | `(&mut self, session: &SessionId) -> Option<Batch>` |
| `self.queues.threads` | `(&self) -> Vec<Thread>`; `Thread { channel, root }` |
| `deliver` | `(poster: &dyn Poster, socket: &Path, batch: &Batch, log: &dyn Fn(&str))`; `Client` is a `Poster` |
| `typing` | `(seq: u64, channel: &str, parent: &str) -> String` |
| `ws.send_text` | `(&mut self, text: &str) -> Result<(), WsError>` |
| `self.log` | `Arc<dyn Fn(&str) + Send + Sync>`: call it as `(self.log)(&line)` |
`?` turns a `StateError` into a `Stop` through the `From` you write first.
## Steps ## Steps
@@ -58,11 +91,11 @@ flag that is never set.
`cp docs/plans/M4a/files/crates/gatewayd/tests/support/fake_mm.rs docs/plans/M4a/files/crates/gatewayd/tests/support/gateway.rs crates/gatewayd/tests/support/`. `cp docs/plans/M4a/files/crates/gatewayd/tests/support/fake_mm.rs docs/plans/M4a/files/crates/gatewayd/tests/support/gateway.rs crates/gatewayd/tests/support/`.
Add `pub mod serve;` to `lib.rs`. Add `pub mod serve;` to `lib.rs`.
- [ ] **2. See it fail.** `cargo test -p gatewayd --no-fail-fast --test serve --test serve_restart`. - [ ] **2. See it fail.** `cargo test -p gatewayd --no-fail-fast --test serve --test serve_restart`.
Expected: it compiles; `serve` 6 fail; `serve_restart` 6 fail and 1 passes (a damaged state file Expected: it compiles; `serve` 6 fail; `serve_restart` 5 fail and 2 pass (a damaged state file
stops `run` before anything you write is called). and a refused token stop `run` before anything you write is called).
- [ ] **3. Fill `mod.rs` first** (`from`, `backoff`, `sleep_unless`, `Gateway::new`, `connect`, - [ ] **3. Fill `mod.rs` first** (`from`, `backoff`, `sleep_unless`, `connected`), **then
`connected`), **then `handle.rs`** (`now_ms`, `post`, `tracked`, `finished`, `typing`, `start`, `handle.rs`** (`now_ms`, `post`, `tracked`, `finished`, `typing`, `start`, `handle_post`,
`handle_post`, `catch_up`). `cargo check -p gatewayd` after each function. `catch_up_channel`). `cargo check -p gatewayd` after each function.
- [ ] **4. See it pass.** `cargo test -p gatewayd --test serve --test serve_restart`, five times. - [ ] **4. See it pass.** `cargo test -p gatewayd --test serve --test serve_restart`, five times.
Expected: 6 and 7 passed each time, in about 2 s. Expected: 6 and 7 passed each time, in about 2 s.
- [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`. - [ ] **5. Run the gate.** `cargo fmt --all`, then `make gate`. Expected last line: `gate: ok`.
@@ -76,4 +109,5 @@ flag that is never set.
- A test passes only sometimes, or a test takes 5 s or more (that is a wait that timed out, not a - A test passes only sometimes, or a test takes 5 s or more (that is a wait that timed out, not a
pass). pass).
- `run` or `event_loop` seems to need a change. They are given; report instead. - A written function (`run`, `connect`, `Gateway::new`, `event_loop`, `catch_up`) seems to need a
change. They are given; report instead.
+12
View File
@@ -78,6 +78,18 @@ At the end of task 15: about 762 tests (650 before task 01).
skeletons were checked in a scratch copy of the branch: they compile and fail their tests, and skeletons were checked in a scratch copy of the branch: they compile and fail their tests, and
filled literally from their comments they pass (7 of 7, 10 of 10 five times), clippy clean, gate filled literally from their comments they pass (7 of 7, 10 of 10 five times), clippy clean, gate
ok. Resume from task 08. ok. Resume from task 08.
- 2026-09-23, task 14: tasks 08 to 13 committed, each on its first session. Task 14's session
wrote nothing, not even the copies: it read twenty source files to learn the APIs, then planned
`connect`'s wait for `hello` in prose ("let me stop designing", and it did not) until it was cut
off. Twelve-plus `todo!()`s across two files and the whole crate's API were too much for one
turn. The design model wrote `connect`, `Gateway::new` and `catch_up` as glue (leaving
`catch_up_channel`), gave `handle_post` and `start` their borrow- and move-sensitive lines
verbatim, added a table of every call the helpers make with its signature (checked against the
branch), and made copying and seeing the tests fail the first two actions. Task 15's `serve`
comment, whose numbered steps had run together, was rewritten one step per item. Both were
checked in a scratch copy of the branch at task 14's start: the skeletons compile and fail
(`serve` 6, `serve_restart` 5 with 2 passing, `main` 4 with 1 passing), and filled from their
comments they pass (five runs), clippy clean, gate ok. Resume from task 14.
## Running it ## Running it
@@ -25,13 +25,20 @@ fn main() -> ExitCode {
} }
fn serve(path: &Path) -> ExitCode { fn serve(path: &Path) -> ExitCode {
// Each failure prints one line (plus its pointer) and returns ExitCode::from(1): // Each failure prints its message with `eprintln!` and returns `ExitCode::from(1)`.
// 1. `Config::load`: "gatewayd: <error>\n<START_FAILED>". 2. `token_source`: "gatewayd: <path>: // 1. `let config = match Config::load(path) { ... }`: an Err(e) prints
// <why>\n<START_FAILED>". // "gatewayd: {e}\n{START_FAILED}".
// 3. `secrets::load(MATTERMOST_TOKEN, &source, &|k| std::env::var_os(k))`: "gatewayd: <error>" // 2. `let source = match config.token_source() { ... }`: an Err(why) prints
// (it carries its pointer). Print the warning, if any, as it is. 4. Create the state file // "gatewayd: {}: {why}\n{START_FAILED}" with `path.display()`.
// directory, recursive, 0700: "gatewayd: cannot prepare <dir>: <e>\n<START_FAILED>". 5. // 3. `let loaded = match secrets::load(MATTERMOST_TOKEN, &source, &|k| std::env::var_os(k))`:
// `run` with Tuning::default(), a log that prints each line to standard error, and a stop // an Err(e) prints "gatewayd: {e}" (the error carries its own pointer).
// flag that is never set; print the Stop it returns. // Then `if let Some(warning) = &loaded.warning { eprintln!("{warning}"); }`.
// 4. The state file's directory:
// `let dir = config.state_path().parent().map(Path::to_path_buf).unwrap_or_default();`
// `std::fs::DirBuilder::new().recursive(true).mode(0o700).create(&dir)`: an Err(e) prints
// "gatewayd: cannot prepare {}: {e}\n{START_FAILED}" with `dir.display()`.
// 5. `let log: gatewayd::serve::Log = Arc::new(|line: &str| eprintln!("{line}"));`
// `let stop = run(config, loaded.secret, Tuning::default(), log, &AtomicBool::new(false));`
// `eprintln!("{stop}");` and `ExitCode::from(1)`.
todo!() todo!()
} }
@@ -21,58 +21,101 @@ fn now_ms() -> i64 {
impl Gateway { impl Gateway {
/// Post in a thread; a failure is logged, not fatal. /// Post in a thread; a failure is logged, not fatal.
pub(super) fn post(&self, channel: &str, root: &str, text: &str) { pub(super) fn post(&self, channel: &str, root: &str, text: &str) {
// `self.client.create_post`; an error is logged, "gatewayd: cannot post in <channel> // `SystemTime::now().duration_since(UNIX_EPOCH).map(|d| d.as_millis()).unwrap_or(0)`, then
// (thread <root>): <error>". // `i64::try_from(ms).unwrap_or(i64::MAX)`.
todo!() todo!()
} }
/// Is this channel one whose posts `gatewayd` keeps track of? /// Is this channel one whose posts `gatewayd` keeps track of?
fn tracked(&self, channel: &str, channel_type: &str) -> bool { fn tracked(&self, channel: &str, channel_type: &str) -> bool {
// channel_type "D", or the channel is in allow.channels. // `channel_type == "D" || self.config.allow.channels.iter().any(|c| c == channel)`.
todo!() todo!()
} }
/// One new post, live or caught up. /// One new post, live or caught up.
pub(super) fn handle_post(&mut self, post: &Post, channel_type: &str) -> Result<(), Stop> { pub(super) fn handle_post(&mut self, post: &Post, channel_type: &str) -> Result<(), Stop> {
// 1. Not tracked, or seen -> nothing. 2. `state.handled(...)?` before anything else. 3. // 1. `if !self.tracked(&post.channel_id, channel_type) || self.state.seen(&post.id)`:
// Route it: NotAllowed -> log "gatewayd: ignored post <id> from <user>: not allowed" // return Ok(()).
// (never the message); other Ignore -> nothing; Reply -> post it; Queue -> join the // 2. `self.state.handled(&post.id, &post.channel_id, post.create_at)?;` before anything
// thread when joins_thread, then push: Start -> `start`, Waiting -> nothing, Full -> // else: after a crash, a post is never answered twice.
// post BUSY. // 3. Route it, with the state answering "is this thread ours?":
// `let state = &self.state;`
// `let route = self.router.route(post, channel_type, &|root| state.knows_thread(root));`
// then `match route`:
// - Route::Ignore(Ignored::NotAllowed): log
// "gatewayd: ignored post <post.id> from <post.user_id>: not allowed"
// (never the text).
// - Route::Ignore(_): nothing.
// - Route::Reply { thread, text }: `self.post(&thread.channel, &thread.root, &text)`.
// - Route::Queue(message): if `message.joins_thread`,
// `self.state.join_thread(&message.thread.root)?`; then
// `match self.queues.push(message)`:
// Pushed::Start(batch) -> `self.start(batch)?`; Pushed::Waiting -> nothing;
// Pushed::Full(thread) -> `self.post(&thread.channel, &thread.root, BUSY)`.
// 4. Ok(()).
todo!() todo!()
} }
/// Record the turn as in flight and run it on its own thread. /// Record the turn as in flight and run it on its own thread.
fn start(&mut self, batch: Batch) -> Result<(), Stop> { fn start(&mut self, batch: Batch) -> Result<(), Stop> {
// `state.start_turn` (session, channel, root). Spawn with std::thread::Builder: `deliver` // 1. `self.state.start_turn(InFlight { session: batch.session.as_str().to_string(),
// with the client, the loop socket, the batch and the log, then send the session on // channel: batch.thread.channel.clone(), root: batch.thread.root.clone() })?;`
// done_tx. If spawning fails: log "gatewayd: cannot start a thread for <session>: <e>", // 2. Clone what the thread takes, before the `move`:
// post LOOP_DOWN in the thread, and send the session on done_tx. // `let (client, socket, log, done) = (Arc::clone(&self.client),
// self.loop_socket.clone(), Arc::clone(&self.log), self.done_tx.clone());`
// `let (session, thread) = (batch.session.clone(), batch.thread.clone());`
// 3. `let spawned = std::thread::Builder::new().spawn(move || {
// deliver(client.as_ref(), &socket, &batch, &|line| log(line));
// let _ = done.send(batch.session); });`
// 4. `if let Err(e) = spawned`: log "gatewayd: cannot start a thread for <session>: <e>"
// (`session.as_str()`), `self.post(&thread.channel, &thread.root, LOOP_DOWN)`, and
// `let _ = self.done_tx.send(session);` so the session does not stay busy.
// 5. Ok(()).
todo!() todo!()
} }
/// Turns that ended: out of flight, and the next batch of each session started. /// Turns that ended: out of flight, and the next batch of each session started.
pub(super) fn finished(&mut self) -> Result<(), Stop> { pub(super) fn finished(&mut self) -> Result<(), Stop> {
// For each session on done_rx (try_recv, never blocking): `end_turn`, then `queues.finish`; // `while let Ok(session) = self.done_rx.try_recv() {` (never blocks)
// a batch it returns is started. // `self.state.end_turn(session.as_str())?;`
// `if let Some(batch) = self.queues.finish(&session) { self.start(batch)?; }` `}`
// Then Ok(()).
todo!() todo!()
} }
/// Show this bot as typing in every thread with a turn running. /// Show this bot as typing in every thread with a turn running.
pub(super) fn typing(&mut self, ws: &mut Ws) -> Result<(), WsError> { pub(super) fn typing(&mut self, ws: &mut Ws) -> Result<(), WsError> {
// For each of `queues.threads()`: seq += 1, send `typing(seq, channel, root)` as text. // `for thread in self.queues.threads() { self.seq += 1;
// ws.send_text(&typing(self.seq, &thread.channel, &thread.root))?; }` then Ok(()).
todo!() todo!()
} }
/// Answer what arrived while `gatewayd` was away: the direct channel with each allowed user, /// Answer what arrived while `gatewayd` was away: the direct channel with each allowed user,
/// and each allowed channel. A channel seen for the first time starts from now. /// and each allowed channel. Written for you: it is the glue.
pub(super) fn catch_up(&mut self) -> Result<(), Stop> { pub(super) fn catch_up(&mut self) -> Result<(), Stop> {
// Channels: the direct channel with each allowed user ("D"; an error is logged "gatewayd: let mut channels = Vec::new();
// no direct channel with <user>: <e>" and skipped), then each allowed channel ("O"). For for user in self.config.allow.users.clone() {
// each: no mark -> `mark(channel, now_ms())` and skip; else `posts_since(channel, mark)` match self.client.direct_channel(&self.me.id, &user) {
// (an error is logged "gatewayd: cannot catch up <channel>: <e>" and skipped); when full, Ok(id) => channels.push((id, "D")),
// log "gatewayd: <channel>: too many posts to catch up; some may be missed"; `handle_post` Err(e) => (self.log)(&format!("gatewayd: no direct channel with {user}: {e}")),
// each post in order. }
}
channels.extend(self.config.allow.channels.iter().map(|c| (c.clone(), "O")));
for (channel, channel_type) in channels {
self.catch_up_channel(&channel, channel_type)?;
}
Ok(())
}
/// One channel's posts since its mark. A channel seen for the first time starts from now.
fn catch_up_channel(&mut self, channel: &str, channel_type: &str) -> Result<(), Stop> {
// 1. `let Some(since) = self.state.since(channel) else { ... }`: with no mark,
// `self.state.mark(channel, now_ms())?` and return Ok(()).
// 2. `self.client.posts_since(channel, since)`: an Err(e) is logged
// "gatewayd: cannot catch up <channel>: <e>" and returns Ok(()).
// 3. When `found.full`, log "gatewayd: <channel>: too many posts to catch up; some may be
// missed".
// 4. `for post in &found.posts { self.handle_post(post, channel_type)?; }` and Ok(()).
todo!() todo!()
} }
} }
@@ -60,7 +60,7 @@ impl std::fmt::Display for Stop {
impl From<StateError> for Stop { impl From<StateError> for Stop {
fn from(e: StateError) -> Stop { fn from(e: StateError) -> Stop {
// Stop::State(e). // `Stop::State(e)`.
todo!() todo!()
} }
} }
@@ -107,14 +107,16 @@ pub(crate) struct Gateway {
/// The wait before attempt `n` (from 0) after a loss. /// The wait before attempt `n` (from 0) after a loss.
pub fn backoff(tuning: &Tuning, n: usize) -> Duration { pub fn backoff(tuning: &Tuning, n: usize) -> Duration {
// tuning.backoff[n], or its last entry when n is past the end (30 s if the list is empty). No // `let last = tuning.backoff.last().copied().unwrap_or(Duration::from_secs(30));` then
// indexing. // `tuning.backoff.get(n).copied().unwrap_or(last)`.
todo!() todo!()
} }
/// Sleep for `d`, in short steps, unless `stop` is set. /// Sleep for `d`, in short steps, unless `stop` is set.
fn sleep_unless(stop: &AtomicBool, d: Duration) { fn sleep_unless(stop: &AtomicBool, d: Duration) {
// Sleep in steps of at most 20 ms until `d` has passed or `stop` is set. // `let until = Instant::now() + d;` then, while `!stop.load(Ordering::SeqCst)` and
// `Instant::now() < until`, sleep
// `Duration::from_millis(20).min(until.saturating_duration_since(Instant::now()))`.
todo!() todo!()
} }
@@ -167,28 +169,69 @@ pub fn run(config: Config, token: Secret, tuning: Tuning, log: Log, stop: &Atomi
/// Who we are, and a WebSocket that has said hello. /// Who we are, and a WebSocket that has said hello.
fn connect(config: &Config, client: &Client, tuning: &Tuning) -> Result<(Me, Ws), MmError> { fn connect(config: &Config, client: &Client, tuning: &Tuning) -> Result<(Me, Ws), MmError> {
// 1. `client.me()?`. 2. Timing from limits (ping_every_ms, dead_after_ms). 3. Open /dev/urandom let me = client.me()?;
// (an error is Net("/dev/urandom: <e>")). 4. `Ws::open(client.connector(), let timing = Timing {
// client.token().expose(), timing, ..)`. ping_every: Duration::from_millis(config.limits.ping_every_ms),
// 5. Poll with tuning.poll until a text that `parse_event`s to Hello, for at most dead_after. dead_after: Duration::from_millis(config.limits.dead_after_ms),
// Every WebSocket error, and no hello in time, is Net("websocket: <why>"). };
todo!() let random =
File::open("/dev/urandom").map_err(|e| MmError::Net(format!("/dev/urandom: {e}")))?;
let net = |e: String| MmError::Net(format!("websocket: {e}"));
let mut ws = Ws::open(
client.connector(),
client.token().expose(),
timing,
Box::new(random),
)
.map_err(|e| net(e.to_string()))?;
// The server says hello first; anything before it is skipped, and silence is an error.
let until = Instant::now() + timing.dead_after;
while Instant::now() < until {
if let Some(text) = ws.poll(tuning.poll).map_err(|e| net(e.to_string()))?
&& matches!(parse_event(&text), Ok(Event::Hello))
{
return Ok((me, ws));
}
}
Err(net("no hello from the server".to_string()))
} }
impl Gateway { impl Gateway {
fn new(config: Config, client: Arc<Client>, state: State, log: Log) -> Gateway { fn new(config: Config, client: Arc<Client>, state: State, log: Log) -> Gateway {
// A done channel, a Router with empty ids (set on connect), an empty Me, loop_socket from let (done_tx, done_rx) = mpsc::channel();
// config, Queues with limit limits.queue (usize::try_from), seq 0, restarted false. let limit = usize::try_from(config.limits.queue).unwrap_or(usize::MAX);
todo!() Gateway {
// Both are set on each connection, from `users/me`.
router: Router::new("", "", &[], &[]),
me: Me {
id: String::new(),
username: String::new(),
},
loop_socket: config.loop_socket(),
queues: Queues::new(limit),
config,
client,
state,
done_tx,
done_rx,
log,
seq: 0,
restarted: false,
}
} }
/// A connection is up: say so, route as this user, and on the first one, answer the turns a /// A connection is up: say so, route as this user, and on the first one, answer the turns a
/// restart cut off. /// restart cut off.
fn connected(&mut self, me: Me) -> Result<(), Stop> { fn connected(&mut self, me: Me) -> Result<(), Stop> {
// Log exactly "gatewayd: connected to <url> as <username>". A new Router from me and the // 1. Log exactly "gatewayd: connected to <url> as <username>"
// allow lists; store me. The first time only (`restarted`): for each turn `take_in_flight` // (`self.config.mattermost.url`, `me.username`), with `(self.log)(&line)`.
// gives back, post INTERRUPTED in its channel and root. A later reconnect must not: those // 2. `let allow = &self.config.allow;`
// turns are still running. // `self.router = Router::new(&me.id, &me.username, &allow.users, &allow.channels);`
// then `self.me = me;`.
// 3. The first time only (`if !self.restarted { self.restarted = true; ... }`): for each
// `turn` in `self.state.take_in_flight()?`, `self.post(&turn.channel, &turn.root,
// INTERRUPTED)`. A later reconnect must not: those turns are still running.
// 4. Ok(()).
todo!() todo!()
} }