@ok-ggwp/git-gloss 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,6 +15,8 @@ git gloss hunks [<commit>|wip] list hunks, numbering changed lines for h3
15
15
  git gloss signals [<commit>|<range>|wip] [--json]
16
16
  what the diff itself says about attention: path classes, pattern
17
17
  hits, dependency bumps and the computed floor
18
+ git gloss lint [<commit>|wip] [--json]
19
+ advisory checks of a gloss that a reviewer would make by eye
18
20
  git gloss adopt [<commit>] move the gloss of uncommitted work onto the commit made from it
19
21
  git gloss add [<commit>] -f a.json attach an annotation (validated against the diff; --allow-underrate)
20
22
  git gloss carry <source>... [--to <c>] gloss a commit from the glosses of the commits its changes came from
@@ -39,6 +41,8 @@ git gloss reviews [<commit>|<range>] the reviews recorded for commits, with the
39
41
  --decide <review-id> --finding <text> --outcome fixed|follow-up|rejected|accepted [--note <text>]
40
42
  record what was decided about one finding
41
43
  --decide <review-id> --complete the arbiter has finished with that review
44
+ --decide-template <review-id> print a JSON skeleton with a row per finding
45
+ --decide <review-id> --decide-file <file|-> record a filled-in skeleton, all or nothing
42
46
  git gloss schema print the annotation JSON Schema
43
47
  ```
44
48
 
@@ -108,7 +112,7 @@ In each repo you use it in:
108
112
 
109
113
  Put the line in the repo rather than in your user-level `CLAUDE.md`: it only makes sense where `git gloss init` has been run.
110
114
 
111
- `sync` fetches the remote's glosses into `refs/notes/remotes/<remote>/gloss`, merges them into yours, and pushes the glosses of commits the remote has (or, from the hook, is being sent), retrying if someone else pushed in between. Glosses of commits that exist only on your machine, such as a private branch, stay local until those commits are pushed. Glosses on different commits simply combine, and a gloss changed on only one side since you last synced is taken from that side. Only when both sides changed the same commit's gloss since then is it a conflict: the newer gloss (by its `created_at`) wins, because a re-gloss is normally a correction, and if either can't be read, yours is kept. It prints what it fetched, pushed and decided. The hook never blocks a push: if syncing fails it prints a warning and the push goes ahead. It does nothing for `git push --dry-run`. It syncs before the branch is pushed (so glosses arrive even if the branch push is then rejected), and it disables credential prompts so a remote that needs one fails fast instead of stalling the push. If you already have a `pre-push` or `post-commit` hook, `init --hooks` leaves it alone and prints the lines to add. It also won't install into a `core.hooksPath` outside the repository, since other repositories may share that directory.
115
+ `sync` fetches the remote's glosses into `refs/notes/remotes/<remote>/gloss`, merges them into yours, and pushes the glosses of commits the remote has (or, from the hook, is being sent), retrying if someone else pushed in between. Glosses of commits that exist only on your machine, such as a private branch, stay local until those commits are pushed. Glosses on different commits simply combine, and a gloss changed on only one side since you last synced is taken from that side. Only when both sides changed the same commit's gloss since then is it a conflict: the newer gloss (by its `created_at`) wins, because a re-gloss is normally a correction, and if either can't be read, yours is kept. It prints what it fetched, pushed and decided. The hook never blocks a push: if syncing fails it prints a warning and the push goes ahead. It does nothing for `git push --dry-run`. It syncs before the branch is pushed (so glosses arrive even if the branch push is then rejected), and it disables credential prompts so a remote that needs one fails fast instead of stalling the push. If you already have a `pre-push` or `post-commit` hook, `init --hooks` leaves it alone and prints the lines to add. It also won't install into a `core.hooksPath` outside the repository, since other repositories may share that directory. If the remote once published `refs/notes/gloss/<something>`, the stale tracking refs under `refs/notes/remotes/<remote>/gloss/` that would block the fetch are deleted first; if a fetch still fails, `git remote prune <remote>` is the fix.
112
116
 
113
117
  ## Glossing before you commit
114
118
 
@@ -124,6 +128,10 @@ git gloss adopt # ...or move it by hand
124
128
 
125
129
  The snapshot is a commit object built from a temporary index, kept by the private ref `refs/worktree/gloss/wip` (one per linked worktree); your branch, index and files are never touched. Taking a new snapshot keeps the gloss of groups whose changes are unchanged, so after committing part of the work or editing one part, only the rest needs glossing again. Its gloss lives in `refs/notes/gloss-wip`, which `sync` doesn't push. Untracked files that look like secrets (`.env`, `*.key`, `*.pem`, `credentials.json`, …) are left out of the snapshot, with a note, because snapshots are shown and sent to reviewers; stage one with `git add` to include it. If a branch or tag is called `wip`, `wip` still means it when there's no snapshot; otherwise use `heads/wip`. Commands warn when you've edited files or committed since the snapshot. `adopt` matches the snapshot's hunks to the commit's by content, so the work can be committed in several commits (each takes the groups whose changes it contains), and changes made after glossing are reported as unexplained rather than silently covered. Once everything is committed, the snapshot is cleared.
126
130
 
131
+ ## Linting a gloss
132
+
133
+ `git gloss lint [<commit>|wip]` checks a gloss deterministically for what a reviewer checking its honesty would look for by eye, and prints one line per finding with a stable code: `no-impact`, `impact-below-floor`, `attention-below-floor`, `mechanical-on-floor` (graded mechanical over a floor signal such as CI or sensitive code; a lockfile bump is fine), `unknown-serves`, `test-serves-nothing`, `required-but-nothing-breaks` (`removal_impact` begins "nothing breaks", "no effect" and the like), `missing-risks` (attention `careful` or above and no `risks`), `config-edit` (the diff edits `.gloss/config.json`, which changes what the signals count; the same note `add` prints) and `uncovered-hunks` (a stale gloss). It is advisory: findings never change the exit code (errors such as no gloss still do). `--json` prints `{target, findings: [{code, group?, hunk?, message}]}`. When the diff signals are unavailable (a malformed `.gloss/config.json`), the rating checks are skipped and lint says `incomplete` (`"incomplete": true` in `--json`) instead of `clean`. Run it after `add` and fix what it reports, or be ready to defend it.
134
+
127
135
  ## Glosses that follow your history
128
136
 
129
137
  A gloss is attached to a commit, so anything that makes new commits from old ones has to bring it along. With `git gloss init --hooks`:
@@ -171,16 +179,31 @@ A review holds a report and a verdict, so by itself it cannot tell "3 fixed, 2 d
171
179
  {"decision":1,"review":"<review id>","at":"…","by":"…","complete":true}
172
180
  ```
173
181
 
174
- `finding` is free text naming the finding (reports are free text, so git-gloss doesn't parse findings out of them); `outcome` is `fixed`, `follow-up`, `rejected` or `accepted`; `note` is optional. The second line says the arbiter has finished and no more decisions are coming, which is what separates "all arbitrated" from "5 ignored". The marker is advisory: decisions added after it are still accepted and counted. A review whose verdict is clean needs none.
182
+ `finding` is free text naming the finding. Reports are free markdown, so git-gloss does not parse findings out of the stored text, but for display (in `reviews` and the html page; the stored report stays raw) it labels every top-level list item `**[S.N]**`, S the section (a `#` heading, a bold-only line such as `**2. Removal**`, or a short plain `3. Title` line with a blank line on each side, followed by a bullet list; its own number if it has one, else the previous section plus one; unnumbered headings before the first item are titles and take none) and N the item's place in it, replacing the source's own bullet or number. Items before the first heading are labelled `[N]` and must be named `N:` or `N)` in `--decide` (`1 wording nit` is free text); a numbered heading (`3. Removal`) counts as the ref `3` for `--decide` although no label is printed for it.
183
+
184
+ Name a decision's finding by that ref, e.g. `--finding "1.2 wording nit"`: `--decide` warns on stderr (and records anyway) when the report has no such ref.
185
+
186
+ Decisions whose finding starts with a ref (`1.2 `, `1.2: `, or a bare `3:`, `3)`, `3.`; numbers over 3 digits are text) are listed in ref order (1.2 before 1.10), the others after them in time order.
187
+
188
+ A finding must have visible text: one that is empty, whitespace or only invisible characters is refused by `--decide` and skipped by the reader. `outcome` is `fixed`, `follow-up`, `rejected` or `accepted`; `note` is optional. The second line says the arbiter has finished and no more decisions are coming, which is what separates "all arbitrated" from "5 ignored". The marker carries no `finding`, `outcome` or `note` (`--complete` refuses them, and the reader skips a marker that has one, or `"complete": false`, which only a hand-written line has). The reader takes the keys in any order. The marker is advisory: decisions added after it are still accepted and counted. A review whose verdict is clean needs none.
175
189
 
176
190
  ```bash
177
191
  git gloss reviews wip --decide <review-id> --finding "Rework: wip rule claim" --outcome follow-up --note "next release"
178
192
  git gloss reviews wip --decide <review-id> --complete
179
193
  ```
180
194
 
181
- The target (`wip`, a commit, a range) is as for `reviews`; the decision is added to every commit there that holds that review, and an unknown review id is an error. `by` is `--by <name>`, else `git config user.name`, else "unknown". `reviews` prints each review's id in its header line (the id `--decide` takes) and lists its decisions under it with a summary ("3 fixed, 2 follow-up · complete", or "4 decided · not marked complete"), and `show` carries the same summary on its reviews line. The HTML view does not show decisions yet. Decisions follow their reviews through `adopt` (so decisions made on `wip` survive committing), `carry`, rebases and `sync`. Text from notes (decisions and review records) and commit subjects is shown with control characters and escape sequences removed, since it can arrive from a remote; decision lines over 16 KB are skipped silently, and review lines over 8 MiB or with findings text over 256 KiB are skipped with a warning; unknown outcomes and decisions for a review that is not on the commit are ignored.
195
+ To decide many findings at once, print a skeleton, fill in each `outcome` (and `note`), and record the file:
182
196
 
183
- Known cost: decisions share the review note, and a git-gloss without this feature ignores decision lines when reading but does not know to keep them: when it copies review records onto another commit (`adopt` and `carry`), it writes only the review records, so the decisions are lost on that copy (git's own note rewriting on an amend or rebase copies whole notes, so that path keeps them). Keep everyone who arbitrates on the same version.
197
+ ```bash
198
+ git gloss reviews wip --decide-template <review-id> > d.json # one row per finding, outcome ""
199
+ git gloss reviews wip --decide <review-id> --decide-file d.json # or - for stdin
200
+ ```
201
+
202
+ The template has a row for each finding, but none for a numbered heading that has items under it (those items are its findings); a hand-written row naming such a heading's bare number (`2`) is still accepted. A report that would give two findings the same ref (two `1.` headings with items, both `1.1`) gets `1/2`, `1/3`, ... (items `1/2.1`) on the later sections; the first keeps `1`. Only an actual clash relabels: a heading ref (`1`) and an item ref (`1.1`) never clash, so an empty numbered heading, an unnumbered heading or a bold line changes nothing, and every report whose refs were all distinct keeps exactly the refs it had. Decisions already recorded under an old, ambiguous ref (only reports with a clash) no longer match after the relabel and are listed as free text. Past `1/999` the label stays `1/999` and its items keep counting on, so no item row is lost. The file is either an array of `{"finding", "outcome", "note"?}` or the template's object `{"review", "complete", "decisions": [...]}` (`review` and unknown keys are ignored, though a `review` that isn't the `--decide` id is refused; `"complete": true` also records the complete marker). Keys may come in any order. Each finding is the ref and the start of its text (`1.2 wording nit`), checked as for the single call, with the same warning for a ref the report lacks. Validation is all or nothing: everything is checked before anything is written (a repeated finding is refused too), and the records go in one write per commit, with a range written commit by commit (so a git failure midway can leave earlier commits written), so a bad row (naming its entry number), an empty `outcome` (an unfilled row), or a file over 500 entries or 1 MiB, or one whose records come to over 512 KiB once written, records nothing. It prints `recorded N decisions on M commits`. Running the same file again is safe for its own author: a record equal to the latest decision for its finding on that commit (same review, finding, outcome, note and `by`) is skipped, so after a partly failed range write the re-run adds only what is missing, and says `recorded N decisions on W commits (S already recorded)`; S counts records, summed over the commits, and includes the complete marker. It is not a merge of arbiters: if someone else decided the same finding differently since, re-running an old file writes its decision again, as the latest. `--decide-template` warns on stderr when refs are ambiguous (a heading row that shares its ref with another finding). It cannot be combined with `--finding`, `--outcome`, `--note`, `--stamp` or `--record`.
203
+
204
+ The target (`wip`, a commit, a range) is as for `reviews`; the decision is added to every commit there that holds that review, and an unknown review id is an error. `by` is `--by <name>`, else `git config user.name`, else "unknown". `reviews` prints each review's id in its header line (the id `--decide` takes) and lists its decisions under it with a summary ("3 fixed, 2 follow-up · complete", or "4 decided · not marked complete"), and `show` carries the same summary on its reviews line. The HTML view (`html` and `ary`) shows the same under each review in its Reviews pane, with an outcome chip per decision, and adds how far they got to the stage's rail row (the latest 200 decisions of a review are listed). Decisions follow their reviews through `adopt` (so decisions made on `wip` survive committing), `carry`, rebases and `sync`. In the terminal, text from notes (decisions and review records) and commit subjects is shown with control characters, escape sequences, and bidi and zero-width characters (U+200B, U+200E, U+200F, U+061C, U+2060-U+2064, U+FEFF, tag characters) removed (each becomes a space; ZWNJ and ZWJ stay, so Persian words and emoji sequences render intact, and so does the soft hyphen), since it can arrive from a remote; decision lines over 16 KB are skipped silently, and review lines over 8 MiB or with findings text over 256 KiB are skipped with a warning; unknown outcomes and decisions for a review that is not on the commit are ignored.
205
+
206
+ Known cost: decisions share the review note, and a git-gloss without this feature ignores decision lines when reading but does not know to keep them: when it copies review records onto another commit (`adopt` and `carry`), it writes only the review records, so the decisions are lost on that copy (git's own note rewriting on an amend or rebase copies whole notes, so that path keeps them). Keep everyone who arbitrates on the same version. Likewise, decisions follow their reviews through `adopt` and `carry` only in versions that know them.
184
207
 
185
208
  Like glosses, reviews follow rebased commits in repos set up with `git gloss init`. Reviews are shared by `git gloss sync` (and so by every push with the hooks) like glosses, for commits the remote has. They never conflict: everyone's reviews are kept. Other tools can record reviews too: `git gloss reviews --stamp <rev>` before the review, then `git gloss reviews --record` with the stamp and the report as JSON on stdin.
186
209
 
@@ -202,11 +225,11 @@ Notes using these fields are format 4; a git-gloss that predates them refuses su
202
225
  - **Floor of the change**: skim for `local`, look for `module`, careful for `shared` and `contract`, must-read for `sensitive`, and at least careful when more than `attention.largeChangeFiles` (default 50) files changed.
203
226
  - **Derived attention** (when the gloss declares none): start from the highest group impact (declared, else the group's floor) with the same mapping, then raise it one step (capped at must-read, once however many apply) for a behaviour-changing group that no test group serves, for a speculative or defensive group outside tests, or for more than `largeChangeFiles` files; never below the floor. A group is *behaviour-changing* when its necessity is not `mechanical` and not all its hunks are tests, docs, generated files or lockfiles. It is *served by tests* when a group made only of test-file hunks names it in `serves`, directly or through groups it serves; a test group serving `"task"` doesn't count.
204
227
 
205
- `add` checks a gloss against the floor. A group whose declared `impact` is below its floor (above plain `module`) is a warning naming the signal. A declared `attention.level` below the floor is an error unless `attention.override_reason` says why; `--allow-underrate` turns that error into a warning. Leaving `impact` or `attention` out is always allowed. An `override_reason` is shown by `show` and `html` next to the floor, flagged as a mismatch.
228
+ `add` checks a gloss against the floor. A group whose declared `impact` is below its floor (above plain `module`) is a warning naming the signal. A declared `attention.level` below the floor is an error unless `attention.override_reason` says why; `--allow-underrate` turns that error into a warning. `add` also warns, once, naming the groups with no `impact` that should have one: every group except a `mechanical` one on plain source (floor `module` or below); a mechanical lockfile bump, say, still counts. It still accepts the gloss, and leaving `impact` or `attention` out is never an error. An `override_reason` is shown by `show` and `html` next to the floor, flagged as a mismatch.
206
229
 
207
230
  ### Config
208
231
 
209
- The built-in defaults name no language, framework or tool, only common file names. A repo adds to them with `.gloss/config.json`, committed in that repo. The file is read from the base branch (the merge base of the checked-out `HEAD` and `origin/main`, `main`, `origin/master` or `master`, so a long-lived branch sees the config as of where it forked; a commit on the base branch itself reads its own config, flagged `sensitive` if it edits the file), never from the working tree, so a change cannot reclassify its own paths; a config edit only takes effect once it is on the base branch. With none of those branches the defaults apply, with a warning. Editing the file is itself always a `sensitive` path. A pattern that takes over 250 ms on a hunk is cut off and from then on counts as matching every changed line (with a warning), so a slow or padded line raises the floor and never lowers it:
232
+ The built-in defaults name no language, framework or tool, only common file names. A repo adds to them with `.gloss/config.json`, committed in that repo. The file is read from the base branch (the merge base of the checked-out `HEAD` and `origin/main`, `main`, `origin/master` or `master`, so a long-lived branch sees the config as of where it forked; a commit on the base branch itself reads its own config, flagged `sensitive` if it edits the file), never from the working tree, so a change cannot reclassify its own paths; a config edit only takes effect once it is on the base branch. With none of those branches the defaults apply, with a warning; if git itself fails reading the file or finding the base, the ratings are unverifiable instead: `add` and `signals` stop with an error, `lint` reports `incomplete`, and `show`, `html` and `hunks` go without signals, so the defaults never stand in for the repo's stricter patterns. Editing the file is itself always a `sensitive` path. A pattern that takes over 250 ms on a hunk is cut off and from then on counts as matching every changed line (with a warning), so a slow or padded line raises the floor and never lowers it:
210
233
 
211
234
  ```json
212
235
  {
@@ -231,19 +254,22 @@ A class is a list that extends the default list, or `{ "extends": false, "globs"
231
254
 
232
255
  Give a range (`git gloss html main..feature --open`) for a branch or PR view: every non-merge commit in order (merges are listed as not reviewed; `A...B` is rejected), a necessity bar across the whole branch, and the sidebar grouped by commit. Commits without a gloss appear with their whole diff marked as unexplained. On range pages a group's link is `#g-<commit>-<group>`. `git gloss review main..feature` likewise covers every commit and adds branch-level questions, such as later commits that undo earlier ones.
233
256
 
234
- All gloss and diff text is rendered as text, and a Content-Security-Policy with a per-page nonce blocks any script the page didn't generate.
257
+ All gloss and diff text is rendered as text. Commit subjects, authors, gloss and review text and decisions are shown with escape sequences and bidi, zero-width and byte-order-mark characters removed, as in the terminal (code lines in a diff are shown as they are). A Content-Security-Policy with a per-page nonce blocks any script the page didn't generate.
235
258
 
236
259
  ## Browsing
237
260
 
238
- `git gloss ary` starts a small web server and opens it in your browser, so you can explore glosses without working out commits, branches and ranges on the command line. The page is the review desk that `git gloss html` writes, with the pickers in its top bar: choose a branch and a branch to compare it with (the repo's `main` or `master` by default); each picker has a search box, for repos with many branches. With a base, the desk's own sidebar lists the commits of the range; with "none" (or when there is nothing beyond the base, or more than 200 commits) the page shows just the branch's newest commit and its groups. To look at anything else, such as `HEAD~3` or `release..main`, type it into the revision box. As everywhere in git-gloss, `wip` there means the snapshot of uncommitted work, which is what opens first once it has been snapshotted (`git gloss hunks wip`); to view a branch or tag called `wip`, type `heads/wip` or `tags/wip`. Every choice loads a new page (`/?rev=…&head=…&base=…`), regenerated from the repo each time, so reloading picks up new glosses and reviews, and the address keeps the selection (with a random port, a bookmark lasts only until you stop the server; pass `--port` for a stable address).
261
+ `git gloss ary` starts a small web server and opens it in your browser, so you can explore glosses without working out commits, branches and ranges on the command line. The page is the review desk that `git gloss html` writes, with the pickers in its top bar: choose a branch and a branch to compare it with (the repo's `main` or `master` by default); each picker has a search box, for repos with many branches. With a base, the desk's own sidebar lists the commits of the range; with "none" (or when there is nothing beyond the base, or more than 200 commits) the page shows just the branch's newest commit and its groups. A typed range of more than 200 commits shows its newest 200 non-merge commits (and the merges since the oldest of them), with a notice that counts the non-merge commits. On a range, a box above the commit list filters the loaded commits by subject, author or short sha (plain text, not a pattern). When a snapshot of uncommitted work exists, an "Uncommitted work (wip)" link opens it, and the wip view says when the working tree has changed since the snapshot (the server never takes one). With no local branches and a detached HEAD, the page shows HEAD (the notice says whether remote branches exist). To look at anything else, such as `HEAD~3` or `release..main`, type it into the revision box. As everywhere in git-gloss, `wip` there means the snapshot of uncommitted work, which is what opens first once it has been snapshotted (`git gloss hunks wip`); to view a branch or tag called `wip`, type `heads/wip` or `tags/wip`. Every choice loads a new page (`/?rev=…&head=…&base=…`), regenerated from the repo each time, so reloading picks up new glosses and reviews, and the address keeps the selection (with a random port, a bookmark lasts only until you stop the server; pass `--port` for a stable address).
262
+
263
+ It serves one repository, the one you run it in, until you press Ctrl-C. It listens on 127.0.0.1 only, on a free port (or `--port`), only answers requests addressed to that host name, and never changes the repository: it doesn't take snapshots, change refs or notes, or add objects to the object store (the wip staleness check builds its tree in a temporary directory, so each wip view costs a full temporary-index `git add -A`, proportional to the working tree; it gives up, saying so, on more than 5000 untracked files or when 10 s in total have passed, or git fails or times out in it). The commit filter and the wip link are part of the page. A page shows at most 200 commits; pick a narrower range for more. `--no-open` doesn't launch a browser.
239
264
 
240
- It serves one repository, the one you run it in, until you press Ctrl-C. It listens on 127.0.0.1 only, on a free port (or `--port`), only answers requests addressed to that host name, and never writes anything: it doesn't take snapshots or change notes. A page shows at most 200 commits; pick a narrower range for more. `--no-open` doesn't launch a browser.
265
+ A page is built in a child process of its own, so a slow or hung git never stops the server answering other requests. A build that outlives `GIT_GLOSS_ARY_TIMEOUT` (ms, default 300000; 0, or more than 2147483647, = no limit) gets a 504 page and its git processes are killed; a single git over `GIT_GLOSS_GIT_TIMEOUT` is a 422 page. At most `GIT_GLOSS_ARY_JOBS` (default 4) pages are built at once, further requests get 503 with `Retry-After` at once; a page over `GIT_GLOSS_ARY_MAX_OUTPUT` bytes (default 32 MiB) is a 502; each running build can hold up to twice that in memory (4 builds: 256 MiB). Closing the browser tab, stopping the server (Ctrl-C, or a closed terminal) or a timeout kills the builds it was running.
241
266
 
242
267
  ## Caveats
243
268
 
244
269
  - Carrying a gloss onto a server-side squash needs the original commits in your repository; usually the author has them. If a squash merge resolved conflicts or changed much of the code, it may not reach the 80% match; run `git gloss carry <branch commits> --to <squash>`.
245
270
  - A gloss records each hunk's location and a hash of its content. If a commit is amended or rebased so its diff changes, `show`, `review` and `why` flag the gloss as stale instead of showing it against the wrong code.
246
271
  - Merge commits are diffed against their first parent.
272
+ - A gloss note over 8 MiB (`MAX_NOTE_BYTES` in `src/schema.ts`) is refused when read, with a message saying so, and `add`, `adopt`, `carry` and `versions --restore` will not write one. In a range, `show`, `html`, `review` and `why` skip a commit whose gloss is refused (too big, not valid, over the caps) with a warning on stderr, as for a commit with no gloss (the html page says the gloss was refused and why); for a single commit it is an error. `versions` prints notes from the history cleaned of control characters, escape sequences and bidi/zero-width characters (`--show` too, so it is not byte-identical to the stored note), and `--restore` only brings back a version readers accept for that commit.
247
273
  - `git gloss html` writes to `~/.cache/git-gloss/<commit>.html` by default (or `$XDG_CACHE_HOME`). The page is a snapshot; regenerate it after re-glossing.
248
274
 
249
275
  ## License
@@ -0,0 +1,112 @@
1
+ import { GlossError, WIP_REF, git, gitTry, resolveCommits, wipStaleness } from '../git.js';
2
+ import { buildPageData } from '../html/data.js';
3
+ import { renderPage } from '../html/page.js';
4
+ import { listRefs } from './browse.js';
5
+ // A range page loads and lays out every commit in it, so a huge one would hang
6
+ // the browser: a longer range shows its newest MAX_RANGE_COMMITS, with a notice.
7
+ export const MAX_RANGE_COMMITS = 200;
8
+ export class BadRequest extends GlossError {
9
+ }
10
+ // A revision, branch or range typed by the user or taken from the sidebar. An
11
+ // option-looking one is refused so it can never be read by git as a flag.
12
+ function revParam(url, name) {
13
+ const v = url.searchParams.get(name);
14
+ if (!v)
15
+ return null;
16
+ if (v.startsWith('-') || v.length > 500 || v.includes('\0'))
17
+ throw new BadRequest(`not a revision: ${v.slice(0, 80)}`);
18
+ return v;
19
+ }
20
+ // Runs in the server process, before any job starts: no git, only string checks.
21
+ export function parseQuery(url) {
22
+ return { head: revParam(url, 'head'), base: revParam(url, 'base'), rev: revParam(url, 'rev') };
23
+ }
24
+ // The commits of `head` the page shows when nothing else is chosen: those
25
+ // beyond `base` (when a page can show them all), or else the newest one (as a sha, so a branch called "wip"
26
+ // doesn't mean the snapshot).
27
+ function defaultRev(head, base) {
28
+ if (!head)
29
+ return null;
30
+ if (base) {
31
+ // A range beyond what a page shows would only be an error page: show the newest commit.
32
+ const n = Number(git(['rev-list', '--count', '--end-of-options', `${base}..${head}`, '--']).trim());
33
+ if (n > 0 && n <= MAX_RANGE_COMMITS)
34
+ return `${base}..${head}`;
35
+ }
36
+ return gitTry(['rev-parse', '--verify', '--quiet', `${head}^{commit}`])?.trim() || null;
37
+ }
38
+ const PAGE_HEADERS = { 'content-security-policy': "frame-ancestors 'none'" };
39
+ // The page for ?rev=&head=&base=. head and base only fill the pickers and
40
+ // choose the default rev; the rev alone decides which commits are shown.
41
+ // Runs git synchronously: only ever called in a job's child process (and by tests).
42
+ export function buildDesk(query) {
43
+ const refs = listRefs();
44
+ const snapshot = gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim() || null;
45
+ const known = new Set([...refs.branches, ...refs.remotes]);
46
+ const { head: wantHead, base: wantBase } = query;
47
+ let rev = query.rev;
48
+ // The default base goes with the default branch.
49
+ const baseFor = (head) => (head === refs.defaultBase ? null : refs.defaultBase);
50
+ // A link or bookmark may name a branch that has since been deleted or
51
+ // renamed: use the usual choice instead, and say so.
52
+ let head = wantHead && known.has(wantHead) ? wantHead : null;
53
+ const fallback = refs.current || refs.defaultBase || refs.branches[0] || null;
54
+ let base;
55
+ let notice = null;
56
+ if (wantHead && !head) {
57
+ head = fallback;
58
+ base = baseFor(head);
59
+ notice = `Branch “${wantHead}” no longer exists; showing ${head ?? 'the default'} instead.`;
60
+ }
61
+ else if (wantBase && !known.has(wantBase)) {
62
+ head ??= fallback;
63
+ base = null;
64
+ notice = `The branch to compare with, “${wantBase}”, no longer exists; showing the newest commit of ${head ?? 'the default'}.`;
65
+ }
66
+ else if (head) {
67
+ base = wantBase;
68
+ }
69
+ else {
70
+ head = fallback;
71
+ base = wantBase ?? baseFor(head);
72
+ }
73
+ if (!rev) {
74
+ // Opening with nothing chosen shows the uncommitted work, if snapshotted;
75
+ // choosing a branch shows that branch.
76
+ const wip = !wantHead && !wantBase && snapshot;
77
+ rev = wip ? 'wip' : defaultRev(head, base);
78
+ if (!rev && head)
79
+ throw new GlossError(`${head} has no commits to show`);
80
+ if (!rev) {
81
+ // No branch to start from (no local branches, a detached HEAD): the checked-out commit.
82
+ rev = 'HEAD';
83
+ notice ??= `There are no ${refs.remotes.length ? 'local ' : ''}branches; showing the newest commit (HEAD).`;
84
+ }
85
+ if (!notice) {
86
+ const p = new URLSearchParams({ rev });
87
+ if (head)
88
+ p.set('head', head);
89
+ if (base)
90
+ p.set('base', base);
91
+ return { status: 302, type: 'text/plain', headers: { location: `/?${p}` }, body: 'redirecting' };
92
+ }
93
+ }
94
+ // "wip" is the latest snapshot of uncommitted work. The server never takes
95
+ // one (that is `git gloss hunks wip`): browsing doesn't change anything.
96
+ const target = rev === 'wip' ? snapshot : rev;
97
+ if (!target)
98
+ throw new GlossError('no snapshot of uncommitted work yet; run `git gloss hunks wip` first');
99
+ const { shas, merges, range, total } = resolveCommits(target, MAX_RANGE_COMMITS);
100
+ if (shas.length < total)
101
+ notice = `${notice ? notice + ' ' : ''}Showing the newest ${MAX_RANGE_COMMITS} of ${total} non-merge commits; narrow the range to see the rest.`;
102
+ // The wip view says when the working tree has moved on from the snapshot. It never takes a new
103
+ // one and writes nothing to the repository (see wipStaleness).
104
+ const stale = rev === 'wip' && snapshot ? wipStaleness(snapshot, true) : null;
105
+ if (stale)
106
+ notice = `${notice ? notice + ' ' : ''}This snapshot is out of date: ${stale}. Run \`git gloss hunks wip\` for a new one.`;
107
+ if (stale === undefined)
108
+ notice = `${notice ? notice + ' ' : ''}Could not check whether the working tree has changed since this snapshot was taken (too large or too slow to compare here).`;
109
+ const data = buildPageData(shas, range, merges);
110
+ data.ary = { repo: refs.repo, branches: refs.branches, remotes: refs.remotes, defaultBase: refs.defaultBase, head, base, rev, notice, wip: !!snapshot };
111
+ return { status: 200, type: 'text/html', headers: PAGE_HEADERS, body: renderPage(data) };
112
+ }
@@ -0,0 +1,132 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { parseGitTimeout } from '../git.js';
4
+ // Pages are built in a child process (src/ary/page-job.ts), never in the server
5
+ // process: git is called synchronously there, and a slow or hung git would stop
6
+ // the server answering anything. Each child leads its own process group, so a
7
+ // job that is cancelled, times out or is stopped takes its git with it.
8
+ const JOB = fileURLToPath(new URL(`./page-job${import.meta.url.endsWith('.ts') ? '.ts' : '.js'}`, import.meta.url));
9
+ const posInt = (raw, fallback) => (raw !== undefined && /^[1-9]\d*$/.test(raw.trim()) ? Number(raw) : fallback);
10
+ // GIT_GLOSS_ARY_JOBS (page builds at once, 4), GIT_GLOSS_ARY_TIMEOUT (ms a build may take, 300000, 0 = no
11
+ // limit; each git call has its own GIT_GLOSS_GIT_TIMEOUT inside it), GIT_GLOSS_ARY_MAX_OUTPUT (bytes, 32 MiB; a build holds up to twice that while the output is joined).
12
+ export function poolOptionsFromEnv(env = process.env) {
13
+ return {
14
+ max: posInt(env.GIT_GLOSS_ARY_JOBS, 4),
15
+ // setTimeout fires at once for more than 2^31-1 ms (24.8 days): a limit that large means none
16
+ timeoutMs: ((t) => (t > 2 ** 31 - 1 ? 0 : t))(parseGitTimeout(env.GIT_GLOSS_ARY_TIMEOUT, 300_000)),
17
+ maxOutput: posInt(env.GIT_GLOSS_ARY_MAX_OUTPUT, 32 * 1024 * 1024),
18
+ };
19
+ }
20
+ // The flags of this process the child needs to run the same code (type stripping on a Node that
21
+ // has it behind a flag, warnings, source maps, heap size). Anything else is left out: an `-e`
22
+ // script, `--input-type` or `--import` of the parent would make the child run something else.
23
+ const KEEP = /^--(no-)?experimental-[\w-]+(=.*)?$|^--(no-warnings|enable-source-maps|disable-warning=.+|max-old-space-size=\d+|stack-size=\d+)$/;
24
+ export const childExecArgv = (argv) => argv.filter((a) => KEEP.test(a));
25
+ // After SIGTERM the group gets this long before SIGKILL, but only while its leader (node) is still
26
+ // alive: the leader dies on SIGTERM at once and its exit sends SIGKILL to the rest of the group, so
27
+ // git rarely gets the grace. ary's git calls are read-only (see TODO).
28
+ const GRACE_MS = 2000;
29
+ export class JobPool {
30
+ live = new Set();
31
+ opts;
32
+ constructor(opts) { this.opts = opts; }
33
+ // null: all slots are taken (the caller answers 503; nothing is queued).
34
+ start(query) {
35
+ if (this.live.size >= this.opts.max)
36
+ return null;
37
+ const { timeoutMs, maxOutput } = this.opts;
38
+ let settle;
39
+ const result = new Promise((resolve) => { settle = resolve; });
40
+ const entry = { killNow: () => { }, cancel: () => { } };
41
+ let child;
42
+ try {
43
+ child = spawn(this.opts.execPath ?? process.execPath, [...(this.opts.execArgv ?? childExecArgv(process.execArgv)), JOB, JSON.stringify(query)], { detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
44
+ }
45
+ catch (err) {
46
+ console.error(err);
47
+ return { result: Promise.resolve({ kind: 'error', status: 500, message: 'internal error' }), cancel() { } };
48
+ }
49
+ this.live.add(entry);
50
+ const pid = child.pid;
51
+ const killGroup = (sig) => { if (pid) {
52
+ try {
53
+ process.kill(-pid, sig);
54
+ }
55
+ catch { /* gone */ }
56
+ } };
57
+ let timers = [];
58
+ let stopping = false;
59
+ const stop = () => {
60
+ if (stopping)
61
+ return;
62
+ stopping = true;
63
+ killGroup('SIGTERM');
64
+ timers.push(setTimeout(() => killGroup('SIGKILL'), GRACE_MS).unref());
65
+ };
66
+ entry.killNow = () => killGroup('SIGKILL');
67
+ entry.cancel = () => { settle({ kind: 'cancelled' }); stop(); };
68
+ if (timeoutMs > 0) {
69
+ timers.push(setTimeout(() => {
70
+ settle({ kind: 'error', status: 504, message: `building this page took longer than ${timeoutMs / 1000} s and was stopped (GIT_GLOSS_ARY_TIMEOUT sets the limit)` });
71
+ stop();
72
+ }, timeoutMs).unref());
73
+ }
74
+ const chunks = [];
75
+ let size = 0;
76
+ let err = '';
77
+ child.stdout.on('data', (d) => {
78
+ size += d.length;
79
+ if (size > maxOutput) {
80
+ chunks.length = 0;
81
+ settle({ kind: 'error', status: 502, message: `the page is more than ${maxOutput} bytes; pick a narrower range` });
82
+ stop();
83
+ return;
84
+ }
85
+ if (!stopping)
86
+ chunks.push(d);
87
+ });
88
+ child.stderr.on('data', (d) => { if (err.length < 8192)
89
+ err += d; });
90
+ child.on('error', (e) => {
91
+ // a spawn failure may not be followed by 'close': free the slot here
92
+ console.error(e);
93
+ for (const t of timers)
94
+ clearTimeout(t);
95
+ this.live.delete(entry);
96
+ settle({ kind: 'error', status: 500, message: 'internal error' });
97
+ });
98
+ child.on('close', (code) => {
99
+ for (const t of timers)
100
+ clearTimeout(t);
101
+ timers = [];
102
+ // a straggler of the group (a git the job left behind) goes with it
103
+ killGroup('SIGKILL');
104
+ this.live.delete(entry);
105
+ if (stopping)
106
+ return settle({ kind: 'cancelled' });
107
+ const all = Buffer.concat(chunks);
108
+ const nl = all.indexOf(10);
109
+ if (code !== 0 || nl < 0) {
110
+ console.error(err.trim() || `page job ended with code ${code}`);
111
+ return settle({ kind: 'error', status: 500, message: 'internal error' });
112
+ }
113
+ try {
114
+ const head = JSON.parse(all.subarray(0, nl).toString('utf8'));
115
+ if (typeof head.error === 'string')
116
+ return settle({ kind: 'error', status: head.status, message: head.error });
117
+ settle({ kind: 'page', status: head.status, type: head.type, headers: head.headers, body: all.subarray(nl + 1) });
118
+ }
119
+ catch (e) {
120
+ console.error(e);
121
+ settle({ kind: 'error', status: 500, message: 'internal error' });
122
+ }
123
+ });
124
+ return { result, cancel: () => entry.cancel() };
125
+ }
126
+ // Stops every running job: SIGTERM now, SIGKILL after the grace period.
127
+ cancelAll() { for (const j of [...this.live])
128
+ j.cancel(); }
129
+ // Synchronous and final, for a process that is exiting.
130
+ killAllNow() { for (const j of this.live)
131
+ j.killNow(); }
132
+ }
@@ -0,0 +1,23 @@
1
+ // Internal entry, run by the ary server as a child process (see jobs.ts): builds
2
+ // one page so that the server's event loop never waits for git. argv[2] is the
3
+ // checked query as JSON. stdout is one JSON line (the status, or an error
4
+ // message) and then the page's bytes. An unexpected failure is left to node:
5
+ // stack on stderr, exit code 1.
6
+ import { GlossError } from '../git.js';
7
+ import { buildDesk } from './desk.js';
8
+ const query = JSON.parse(process.argv[2]);
9
+ let head;
10
+ let body = '';
11
+ try {
12
+ const r = buildDesk(query);
13
+ head = { status: r.status, type: r.type, headers: r.headers };
14
+ body = r.body;
15
+ }
16
+ catch (err) {
17
+ if (!(err instanceof GlossError))
18
+ throw err;
19
+ head = { status: 422, error: err.message };
20
+ }
21
+ // No process.exit: let the pipe drain, a large page is written in pieces.
22
+ process.stdout.write(JSON.stringify(head) + '\n');
23
+ process.stdout.write(body);
@@ -158,6 +158,14 @@ function aryBar(ary, el, add) {
158
158
  function field(label, node) { return add(el('div', 'ary-field'), el('span', null, label), node) }
159
159
  var bar = add(el('div', 'ary-bar'), el('span', 'ary-repo', ary.repo),
160
160
  field('Branch', headPicker.root), field('Compare with', basePicker.root), field('Revision', form))
161
+ if (ary.wip) {
162
+ var wip = el('a', 'ary-wip', 'Uncommitted work (wip)')
163
+ var q = new URLSearchParams({ rev: 'wip' })
164
+ if (ary.head) q.set('head', ary.head)
165
+ if (ary.base) q.set('base', ary.base)
166
+ wip.href = '/?' + q.toString()
167
+ bar.appendChild(wip)
168
+ }
161
169
  if (ary.notice) bar.appendChild(el('p', 'ary-notice', ary.notice))
162
170
  return bar
163
171
  }
@@ -1,26 +1,9 @@
1
1
  import { createServer } from 'node:http';
2
- import { GlossError, WIP_REF, git, gitTry, resolveCommits } from '../git.js';
3
- import { buildPageData } from '../html/data.js';
4
- import { escapeHtml, renderPage } from '../html/page.js';
5
- import { listRefs } from './browse.js';
6
- // A range page loads and lays out every commit in it, so a huge one would hang
7
- // the browser. Narrowing it is a better answer than a spinner.
8
- export const MAX_RANGE_COMMITS = 200;
9
- class BadRequest extends GlossError {
10
- }
11
- // A revision, branch or range typed by the user or taken from the sidebar. An
12
- // option-looking one is refused so it can never be read by git as a flag.
13
- function revParam(url, name, required = true) {
14
- const v = url.searchParams.get(name);
15
- if (!v) {
16
- if (required)
17
- throw new BadRequest(`missing ?${name}=`);
18
- return null;
19
- }
20
- if (v.startsWith('-') || v.length > 500 || v.includes('\0'))
21
- throw new BadRequest(`not a revision: ${v.slice(0, 80)}`);
22
- return v;
23
- }
2
+ import { GlossError } from '../git.js';
3
+ import { escapeHtml } from '../html/page.js';
4
+ import { BadRequest, parseQuery } from './desk.js';
5
+ import { JobPool, poolOptionsFromEnv } from './jobs.js';
6
+ export { MAX_RANGE_COMMITS } from './desk.js';
24
7
  function send(res, status, type, body, headers = {}) {
25
8
  res.writeHead(status, {
26
9
  'content-type': `${type}; charset=utf-8`,
@@ -32,94 +15,33 @@ function send(res, status, type, body, headers = {}) {
32
15
  });
33
16
  res.end(body);
34
17
  }
35
- // The page may not be framed by anyone: it shows the code of your commits.
36
- const PAGE_HEADERS = { 'content-security-policy': "frame-ancestors 'none'" };
37
18
  function errorPage(res, status, message) {
38
19
  send(res, status, 'text/html', `<!doctype html><meta charset="utf-8"><meta name="color-scheme" content="light dark"><pre style="white-space:pre-wrap;font:14px/1.5 system-ui;padding:16px">${escapeHtml(message)}</pre><p style="font:14px/1.5 system-ui;padding:0 16px"><a href="/">Back to the default view</a></p>`, { 'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'none'" });
39
20
  }
40
- // The commits of `head` the page shows when nothing else is chosen: those
41
- // beyond `base` (when a page can show them all), or else the newest one (as a sha, so a branch called "wip"
42
- // doesn't mean the snapshot).
43
- function defaultRev(head, base) {
44
- if (!head)
45
- return null;
46
- if (base) {
47
- // A range beyond what a page shows would only be an error page: show the newest commit.
48
- const n = Number(git(['rev-list', '--count', '--end-of-options', `${base}..${head}`, '--']).trim());
49
- if (n > 0 && n <= MAX_RANGE_COMMITS)
50
- return `${base}..${head}`;
51
- }
52
- return gitTry(['rev-parse', '--verify', '--quiet', `${head}^{commit}`])?.trim() || null;
53
- }
54
- // The page for ?rev=&head=&base=. head and base only fill the pickers and
55
- // choose the default rev; the rev alone decides which commits are shown.
56
- function deskPage(url, res) {
57
- const refs = listRefs();
58
- const known = new Set([...refs.branches, ...refs.remotes]);
59
- const wantHead = revParam(url, 'head', false);
60
- const wantBase = revParam(url, 'base', false);
61
- let rev = revParam(url, 'rev', false);
62
- // The default base goes with the default branch.
63
- const baseFor = (head) => (head === refs.defaultBase ? null : refs.defaultBase);
64
- // A link or bookmark may name a branch that has since been deleted or
65
- // renamed: use the usual choice instead, and say so.
66
- let head = wantHead && known.has(wantHead) ? wantHead : null;
67
- const fallback = refs.current || refs.defaultBase || refs.branches[0] || null;
68
- let base;
69
- let notice = null;
70
- if (wantHead && !head) {
71
- head = fallback;
72
- base = baseFor(head);
73
- notice = `Branch \u201c${wantHead}\u201d no longer exists; showing ${head ?? 'the default'} instead.`;
74
- }
75
- else if (wantBase && !known.has(wantBase)) {
76
- head ??= fallback;
77
- base = null;
78
- notice = `The branch to compare with, \u201c${wantBase}\u201d, no longer exists; showing the newest commit of ${head ?? 'the default'}.`;
79
- }
80
- else if (head) {
81
- base = wantBase;
82
- }
83
- else {
84
- head = fallback;
85
- base = wantBase ?? baseFor(head);
86
- }
87
- if (!rev) {
88
- // Opening with nothing chosen shows the uncommitted work, if snapshotted;
89
- // choosing a branch shows that branch.
90
- const wip = !wantHead && !wantBase && gitTry(['rev-parse', '--verify', '--quiet', WIP_REF]);
91
- rev = wip ? 'wip' : defaultRev(head, base);
92
- if (!rev)
93
- throw new GlossError(head ? `${head} has no commits to show` : 'there are no branches to show; pass ?rev= to view a commit');
94
- if (!notice) {
95
- const p = new URLSearchParams({ rev });
96
- if (head)
97
- p.set('head', head);
98
- if (base)
99
- p.set('base', base);
100
- return send(res, 302, 'text/plain', 'redirecting', { location: `/?${p}` });
101
- }
102
- }
103
- // "wip" is the latest snapshot of uncommitted work. The server never takes
104
- // one (that is `git gloss hunks wip`): browsing doesn't change anything.
105
- const target = rev === 'wip' ? gitTry(['rev-parse', '--verify', '--quiet', WIP_REF])?.trim() : rev;
106
- if (!target)
107
- throw new GlossError('no snapshot of uncommitted work yet; run `git gloss hunks wip` first');
108
- const { shas, merges, range } = resolveCommits(target);
109
- if (shas.length > MAX_RANGE_COMMITS) {
110
- throw new GlossError(`${rev} has ${shas.length} commits; a page shows at most ${MAX_RANGE_COMMITS}. Pick a narrower range.`);
111
- }
112
- const data = buildPageData(shas, range, merges);
113
- data.ary = { repo: refs.repo, branches: refs.branches, remotes: refs.remotes, defaultBase: refs.defaultBase, head, base, rev, notice };
114
- send(res, 200, 'text/html', renderPage(data), PAGE_HEADERS);
115
- }
116
- function route(res, url) {
117
- if (url.pathname === '/')
118
- return deskPage(url, res);
119
- send(res, 404, 'text/plain', 'not found');
21
+ // The page is built in a child process (jobs.ts), so git never blocks this loop. The query is
22
+ // checked here first: a refused request starts nothing. Answers: 503 + Retry-After when
23
+ // all slots are busy, 504 when the build outlives GIT_GLOSS_ARY_TIMEOUT, 502 when its
24
+ // output passes GIT_GLOSS_ARY_MAX_OUTPUT, 422 for what git or the repo says. A client that
25
+ // leaves cancels its job.
26
+ function route(res, url, pool) {
27
+ if (url.pathname !== '/')
28
+ return send(res, 404, 'text/plain', 'not found');
29
+ const job = pool.start(parseQuery(url));
30
+ if (!job)
31
+ return send(res, 503, 'text/plain', 'busy: too many pages are being built; retry in a moment', { 'retry-after': '1' });
32
+ res.on('close', () => { if (!res.writableFinished)
33
+ job.cancel(); });
34
+ job.result.then((r) => {
35
+ if (r.kind === 'cancelled' || res.destroyed || res.writableEnded)
36
+ return;
37
+ if (r.kind === 'error')
38
+ return r.status === 500 ? send(res, 500, 'text/plain', 'internal error') : errorPage(res, r.status, r.message);
39
+ send(res, r.status, r.type, r.body, r.headers);
40
+ });
120
41
  }
121
- export function createAry(port) {
122
- return createServer((req, res) => {
42
+ export function createAry(port, overrides = {}) {
43
+ const pool = new JobPool({ ...poolOptionsFromEnv(), ...overrides });
44
+ const server = createServer((req, res) => {
123
45
  // A web page on another site can make the browser send requests here, and
124
46
  // with DNS rebinding even read the answers. Only our own origin's host
125
47
  // names get through; the server is read-only besides.
@@ -137,7 +59,7 @@ export function createAry(port) {
137
59
  catch {
138
60
  throw new BadRequest('not a valid request target');
139
61
  }
140
- route(res, url);
62
+ route(res, url, pool);
141
63
  }
142
64
  catch (err) {
143
65
  if (!(err instanceof GlossError)) {
@@ -148,4 +70,16 @@ export function createAry(port) {
148
70
  errorPage(res, status, err.message);
149
71
  }
150
72
  });
73
+ // Closing the server stops what it is building, and nothing outlives the process.
74
+ const exitKill = () => pool.killAllNow();
75
+ process.on('exit', exitKill);
76
+ server.on('close', () => process.off('exit', exitKill));
77
+ const close = server.close.bind(server);
78
+ server.close = (cb) => {
79
+ pool.cancelAll();
80
+ const r = close(cb);
81
+ server.closeAllConnections();
82
+ return r;
83
+ };
84
+ return server;
151
85
  }