@kabmo-labs/kabmo 0.8.0 → 0.9.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.
Files changed (254) hide show
  1. package/README.md +7 -0
  2. package/dist/client/index.html +1 -1
  3. package/dist/client/index.js +314 -314
  4. package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
  5. package/dist/client/static/js/index-k315p1e8.css +1 -0
  6. package/package.json +9 -1
  7. package/src/client/components/common/code-surface/documents.test.ts +120 -0
  8. package/src/client/components/common/code-surface/documents.ts +201 -0
  9. package/src/client/components/common/code-surface/extensions.test.ts +110 -0
  10. package/src/client/components/common/code-surface/extensions.ts +162 -0
  11. package/src/client/components/common/code-surface/handle.ts +102 -0
  12. package/src/client/components/common/code-surface/index.tsx +253 -314
  13. package/src/client/components/common/code-surface/keymap.test.ts +133 -0
  14. package/src/client/components/common/code-surface/keymap.ts +267 -0
  15. package/src/client/components/common/code-surface/marks.ts +74 -0
  16. package/src/client/components/common/code-surface/shiki.ts +234 -0
  17. package/src/client/components/common/code-surface/sync.test.ts +185 -0
  18. package/src/client/components/common/code-surface/sync.ts +58 -0
  19. package/src/client/components/common/code-surface/theme.test.ts +202 -0
  20. package/src/client/components/common/code-surface/theme.ts +111 -0
  21. package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
  22. package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
  23. package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
  24. package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
  25. package/src/client/components/common/shell-update-button/index.tsx +125 -0
  26. package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
  27. package/src/client/components/common/toolbar-row/index.tsx +189 -0
  28. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
  29. package/src/client/components/layout/desktop-layout/index.tsx +47 -67
  30. package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
  31. package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
  32. package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
  33. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
  34. package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
  35. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
  36. package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
  37. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
  38. package/src/client/components/settings/Modal.tsx +8 -0
  39. package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
  40. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
  41. package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
  42. package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
  43. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
  44. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
  45. package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
  46. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
  47. package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
  48. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
  49. package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
  50. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
  51. package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
  52. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
  53. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
  54. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
  55. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
  56. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
  57. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
  58. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
  59. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
  60. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
  61. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
  62. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
  63. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
  64. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
  65. package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
  66. package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
  67. package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
  68. package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
  69. package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
  70. package/src/client/components/workspace/diff-panel/index.tsx +76 -145
  71. package/src/client/components/workspace/diff-panel/merge.ts +315 -0
  72. package/src/client/components/workspace/editor/Body.tsx +4 -7
  73. package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
  74. package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
  75. package/src/client/components/workspace/editor/FilePane.tsx +4 -7
  76. package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
  77. package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
  78. package/src/client/components/workspace/editor/index.tsx +7 -9
  79. package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
  80. package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
  81. package/src/client/components/workspace/search-panel/Body.tsx +1 -1
  82. package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
  83. package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
  84. package/src/client/hooks/chat/omp/reconnect.ts +187 -0
  85. package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
  86. package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
  87. package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
  88. package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
  89. package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
  90. package/src/client/hooks/chat/omp/session-list.tsx +69 -9
  91. package/src/client/hooks/chat/timeline/generating.ts +19 -2
  92. package/src/client/hooks/chat/timeline/index.ts +19 -24
  93. package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
  94. package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
  95. package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
  96. package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
  97. package/src/client/hooks/chat/timeline/session-load.ts +69 -37
  98. package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
  99. package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
  100. package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
  101. package/src/client/hooks/code/deferred-highlight.ts +129 -0
  102. package/src/client/hooks/editor/find-state.ts +1 -1
  103. package/src/client/hooks/editor/theme.test.ts +206 -0
  104. package/src/client/hooks/editor/theme.ts +82 -0
  105. package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
  106. package/src/client/hooks/editor/use-editor-drop.ts +67 -14
  107. package/src/client/hooks/editor/use-editor-find.ts +7 -7
  108. package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
  109. package/src/client/hooks/editor/use-outline-view.ts +94 -7
  110. package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
  111. package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
  112. package/src/client/hooks/editor/use-search-reveal.ts +1 -1
  113. package/src/client/hooks/ui/settings-modal.ts +56 -0
  114. package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
  115. package/src/client/hooks/ui/updates/shell-download.ts +55 -14
  116. package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
  117. package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
  118. package/src/client/hooks/ui/updates/shell-update.ts +119 -0
  119. package/src/client/hooks/workspace/diff-file.ts +29 -10
  120. package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
  121. package/src/client/hooks/workspace/editor-view.ts +61 -0
  122. package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
  123. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  124. package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
  125. package/src/client/hooks/workspace/worktree/index.ts +20 -5
  126. package/src/client/lib/desktop/bridge.ts +45 -0
  127. package/src/client/lib/search-reveal.ts +1 -1
  128. package/src/client/main.tsx +5 -1
  129. package/src/client/tailwind.css +37 -8
  130. package/src/server/lib/auth/guard.test.ts +28 -0
  131. package/src/server/lib/auth/guard.ts +10 -0
  132. package/src/server/lib/fs/worktree/create.ts +2 -3
  133. package/src/server/lib/fs/worktree/list.ts +13 -0
  134. package/src/server/lib/fs/worktree/remove.ts +3 -3
  135. package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
  136. package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
  137. package/src/server/lib/lifecycle/port-guard.ts +39 -7
  138. package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
  139. package/src/server/lib/lifecycle/probe.test.ts +77 -164
  140. package/src/server/lib/lifecycle/probe.ts +121 -13
  141. package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
  142. package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
  143. package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
  144. package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
  145. package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
  146. package/src/server/lib/omp/session/telemetry.test.ts +33 -0
  147. package/src/server/lib/realtime/signals.server.ts +10 -0
  148. package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
  149. package/src/server/lib/realtime/topics.server.ts +16 -0
  150. package/src/server/lib/updates/shell-assets.ts +94 -3
  151. package/src/server/lib/updates/shell-cache.ts +105 -0
  152. package/src/server/lib/updates/shell-pages.test.ts +33 -2
  153. package/src/server/lib/updates/shell-pages.ts +17 -5
  154. package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
  155. package/src/server/lib/updates/shell-release.test.ts +10 -27
  156. package/src/server/lib/updates/shell-release.ts +107 -111
  157. package/src/server/routes/fs/git-merge.test.ts +11 -1
  158. package/src/server/routes/fs/read.test.ts +24 -7
  159. package/src/server/routes/fs/scope.test.ts +15 -10
  160. package/src/server/routes/fs/scope.ts +3 -14
  161. package/src/server/routes/updates/android.ts +6 -1
  162. package/src/server/routes/updates/desktop.ts +29 -7
  163. package/src/server/routes/updates/ios.ts +5 -1
  164. package/src/server/routes/worktrees/list.ts +11 -1
  165. package/src/server/routes/worktrees/routes.test.ts +17 -0
  166. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
  167. package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
  168. package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
  169. package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
  170. package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
  171. package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
  172. package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
  173. package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
  174. package/src/shared/lib/code/editor/find-scan.ts +177 -0
  175. package/src/shared/lib/code/editor/keymap.ts +3 -3
  176. package/src/shared/lib/code/editor/lines.test.ts +7 -55
  177. package/src/shared/lib/code/editor/lines.ts +8 -48
  178. package/src/shared/lib/code/editor/marks.test.ts +4 -5
  179. package/src/shared/lib/code/editor/marks.ts +13 -25
  180. package/src/shared/lib/code/editor/theme-loader.ts +97 -0
  181. package/src/shared/lib/code/editor/themes.generated.ts +156 -0
  182. package/src/shared/lib/code/editor/themes.test.ts +165 -0
  183. package/src/shared/lib/code/editor/themes.ts +165 -0
  184. package/src/shared/lib/code/highlight-memo.test.ts +103 -0
  185. package/src/shared/lib/code/highlight-memo.ts +149 -0
  186. package/src/shared/lib/code/highlighter-warmup.ts +112 -0
  187. package/src/shared/lib/code/highlighter.ts +3 -1
  188. package/src/shared/lib/code/idle.ts +37 -0
  189. package/src/shared/lib/code/outline/rows.test.ts +96 -0
  190. package/src/shared/lib/code/outline/rows.ts +94 -0
  191. package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
  192. package/src/shared/lib/code/syntax-highlight.ts +2 -3
  193. package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
  194. package/src/shared/lib/fs/file-kind.ts +47 -13
  195. package/src/shared/lib/fs/file-url.test.ts +52 -0
  196. package/src/shared/lib/fs/file-url.ts +37 -0
  197. package/src/shared/lib/fs/patch-sides.test.ts +114 -0
  198. package/src/shared/lib/fs/patch-sides.ts +139 -0
  199. package/src/shared/lib/realtime/protocol.ts +16 -0
  200. package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
  201. package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
  202. package/src/shared/lib/updates/shell-arch.ts +54 -0
  203. package/src/shared/lib/updates/shell-ua.ts +4 -4
  204. package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
  205. package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
  206. package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
  207. package/src/shared/lib/workspace/panel-widths.ts +25 -5
  208. package/src/shared/lib/workspace/session-state/cache.ts +159 -0
  209. package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
  210. package/src/shared/lib/workspace/session-state/merge.ts +100 -0
  211. package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
  212. package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
  213. package/src/shared/lib/workspace/session-state/store.ts +92 -130
  214. package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
  215. package/src/shared/types/realtime.ts +29 -0
  216. package/src/shared/types/settings/state.ts +10 -0
  217. package/src/test-support/loopback.ts +183 -0
  218. package/dist/client/static/js/index-gtbpewr7.css +0 -1
  219. package/src/client/components/common/code-editor/handler.test.ts +0 -274
  220. package/src/client/components/common/code-editor/handler.ts +0 -175
  221. package/src/client/components/common/code-editor/index.tsx +0 -314
  222. package/src/client/components/common/code-surface/measure.ts +0 -148
  223. package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
  224. package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
  225. package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
  226. package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
  227. package/src/client/hooks/editor/scroll-container.test.ts +0 -102
  228. package/src/client/hooks/editor/scroll-container.ts +0 -26
  229. package/src/client/hooks/editor/use-code-window.ts +0 -100
  230. package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
  231. package/src/client/hooks/editor/use-editor-history.ts +0 -134
  232. package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
  233. package/src/client/hooks/editor/use-occurrences.ts +0 -98
  234. package/src/shared/lib/code/editor/commands.test.ts +0 -155
  235. package/src/shared/lib/code/editor/commands.ts +0 -260
  236. package/src/shared/lib/code/editor/edits.test.ts +0 -100
  237. package/src/shared/lib/code/editor/edits.ts +0 -122
  238. package/src/shared/lib/code/editor/history.test.ts +0 -118
  239. package/src/shared/lib/code/editor/history.ts +0 -101
  240. package/src/shared/lib/code/editor/layers.test.ts +0 -77
  241. package/src/shared/lib/code/editor/layers.ts +0 -67
  242. package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
  243. package/src/shared/lib/code/editor/line-edits.ts +0 -159
  244. package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
  245. package/src/shared/lib/code/editor/occurrences.ts +0 -111
  246. package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
  247. package/src/shared/lib/code/editor/reset-css.ts +0 -32
  248. package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
  249. package/src/shared/lib/code/editor/text-commands.ts +0 -154
  250. package/src/shared/lib/code/lazy-window.test.ts +0 -161
  251. package/src/shared/lib/code/lazy-window.ts +0 -156
  252. package/src/shared/lib/code/windowed-highlight.ts +0 -114
  253. package/src/shared/lib/fs/diff-parser.test.ts +0 -116
  254. package/src/shared/lib/fs/diff-parser.ts +0 -213
@@ -4,22 +4,18 @@
4
4
  */
5
5
 
6
6
  /**
7
- * `port-guard.ts` decides whether this process may take a port, and its whole
8
- * value is in the failure path: a starter that loses the race must fail with a
9
- * message that names the real situation instead of a bare `EADDRINUSE`. These
10
- * tests therefore drive both mechanisms the module can pick — the `flock(2)`
11
- * lease and the PID-file fallback — and both claim outcomes: the bind succeeds,
12
- * or the error text carries the occupant / the underlying bind error.
7
+ * `acquirePortLock` — the per-port startup lease, which exists so two starters
8
+ * racing on one free port fail with a message that names the real situation
9
+ * instead of a bare `EADDRINUSE`.
13
10
  *
14
- * The fallback is forced where its recovery logic is the subject (a dead
15
- * holder, a recycled PID): `flock` ignores the lock file's contents, so those
16
- * branches are otherwise unreachable on this host. Everything runs against
17
- * temp data directories and OS-assigned ports; the only subprocesses are
18
- * throwaway `bun -e` holders, never an `omp` server.
11
+ * Both mechanisms are driven: the `flock(2)` lease and the PID-file fallback.
12
+ * The fallback is FORCED where its recovery logic is the subject (a dead
13
+ * holder, a recycled PID) — `flock` keys on the inode and ignores the file's
14
+ * contents, so those branches are otherwise unreachable on this host. The claim
15
+ * itself is `claim-port`.
19
16
  */
20
17
 
21
18
  import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
22
- import type { Server } from 'bun';
23
19
  import fs from 'node:fs';
24
20
  import os from 'node:os';
25
21
  import path from 'node:path';
@@ -29,18 +25,15 @@ import { flockAvailable } from '@/server/lib/lifecycle/flock';
29
25
  import type { FlockHandle } from '@/server/lib/lifecycle/flock';
30
26
  import { getProcessState } from '@/server/lib/lifecycle/identity';
31
27
  import { getLockPath } from '@/server/lib/lifecycle/paths';
32
- import { acquirePortLock, claimPort, PortInUseError } from '@/server/lib/lifecycle/port-guard';
28
+ import { acquirePortLock } from '@/server/lib/lifecycle/port-guard';
33
29
  import type { PortLock } from '@/server/lib/lifecycle/port-guard';
34
- import { waitForPortFree } from '@/server/lib/lifecycle/probe';
30
+ import { createLoopbackHarness, realFetch } from '@/test-support/loopback';
31
+
32
+ const harness = createLoopbackHarness();
35
33
 
36
34
  let tempDir: string;
37
35
  let originalDataDir: string | undefined;
38
- /** The runner's own fetch, captured before any test runs. */
39
- /** The runner's own fetch, reached through `Bun` so a stub leaked onto the global cannot be mistaken for it. */
40
- const realFetch = Bun.fetch;
41
36
  const leases: (PortLock | FlockHandle)[] = [];
42
- const children: Bun.Subprocess[] = [];
43
- const servers: Server<undefined>[] = [];
44
37
 
45
38
  /** The flock singleton is anchored on `globalThis`; leaked state spans suites. */
46
39
  function flockHost(): { __kabmoFlock?: unknown } {
@@ -63,80 +56,17 @@ function track<T extends PortLock | FlockHandle>(lease: T | null): T | null {
63
56
  return lease;
64
57
  }
65
58
 
66
- /**
67
- * A real process whose argv says `kabmo`, i.e. `matched` to identity.ts.
68
- *
69
- * The child announces itself before it parks, and that line is what says the OS
70
- * has finished `exec`: a freshly spawned process's command line is unreadable
71
- * for the instant between fork and exec (`/proc/<pid>/cmdline` reads empty
72
- * there, so Linux's probe falls back to `comm` and identity answers
73
- * `mismatched` for a process that IS ours). Production reads a PID out of a
74
- * lock file written by a process started long before, so it never sees that
75
- * window — no fixed delay to guess at here.
76
- */
77
- async function spawnChamberHolder(): Promise<Bun.Subprocess> {
78
- const child = Bun.spawn({
79
- cmd: ['bun', '-e', 'console.log("ready"); await new Promise(() => {})', 'kabmo-holder'],
80
- stdout: 'pipe',
81
- stderr: 'ignore',
82
- });
83
- children.push(child);
84
- const reader = (child.stdout as ReadableStream<Uint8Array>).getReader();
85
- await reader.read();
86
- reader.releaseLock();
87
- expect(getProcessState(child.pid)).toBe('matched');
88
- return child;
89
- }
90
-
91
- /** A PID that has exited and been reaped, so it reads as `dead`. */
92
- async function deadPid(): Promise<number> {
93
- const child = Bun.spawn({ cmd: ['bun', '-e', 'process.exit(0)'], stdout: 'ignore', stderr: 'ignore' });
94
- const pid = child.pid;
95
- await child.exited;
96
- expect(getProcessState(pid)).toBe('dead');
97
- return pid;
98
- }
99
-
100
- /** An OS-assigned port, released and confirmed free before use. */
101
- async function freePort(): Promise<number> {
102
- const server = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('unused') });
103
- const port = server.port;
104
- if (port === undefined) throw new Error('the OS did not assign an ephemeral port');
105
- server.stop(true);
106
- if (!(await waitForPortFree(port, '127.0.0.1', 2_000, 25))) throw new Error(`ephemeral port ${port} never became free`);
107
- return port;
108
- }
109
-
110
- /** A live listener on an OS-assigned port, held for the whole test. */
111
- /** A live listener on an OS-assigned port; the guard reads the assigned
112
- * number, never `server.port` (`number | undefined` in Bun's types). */
113
- function occupy(fetch: (request: Request) => Response): number {
114
- const server = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch });
115
- const port = server.port;
116
- if (port === undefined) throw new Error('the OS did not assign an ephemeral port');
117
- servers.push(server);
118
- return port;
119
- }
120
-
121
59
  beforeEach(() => {
122
60
  originalDataDir = Bun.env.KABMO_DATA_DIR;
123
61
  tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-test-port-guard-'));
124
62
  Bun.env.KABMO_DATA_DIR = tempDir;
125
- // `claimPort` asks the occupant whether it is a Kabmo server, over the
126
- // global fetch. A stub another file left installed would answer for it.
63
+ // The guard asks the occupant whether it is a Kabmo server, over the global
64
+ // fetch. A stub another file left installed would answer for it.
127
65
  globalThis.fetch = realFetch;
128
66
  });
129
67
 
130
68
  afterEach(async () => {
131
- for (const child of children.splice(0)) {
132
- try {
133
- child.kill('SIGKILL');
134
- await child.exited;
135
- } catch {
136
- // Already gone.
137
- }
138
- }
139
- for (const server of servers.splice(0)) server.stop(true);
69
+ await harness.dispose();
140
70
  for (const lease of leases.splice(0)) lease.release();
141
71
  delete flockHost().__kabmoFlock;
142
72
  if (originalDataDir === undefined) delete Bun.env.KABMO_DATA_DIR;
@@ -176,7 +106,7 @@ describe('acquirePortLock', () => {
176
106
  test('reclaims a lock whose holder is dead', async () => {
177
107
  usePidFileFallback();
178
108
  const lockPath = getLockPath(4213);
179
- fs.writeFileSync(lockPath, String(await deadPid()));
109
+ fs.writeFileSync(lockPath, String(await harness.deadPid()));
180
110
 
181
111
  const reclaimed = track(await acquirePortLock(4213, 0));
182
112
 
@@ -194,17 +124,16 @@ describe('acquirePortLock', () => {
194
124
  usePidFileFallback();
195
125
  // A live PID that is not Kabmo means the recorded instance is gone;
196
126
  // refusing here would block startup on a free port forever.
197
- const stranger = Bun.spawn({ cmd: ['bun', '-e', 'await new Promise(() => {})'], stdout: 'ignore', stderr: 'ignore' });
198
- children.push(stranger);
199
- expect(getProcessState(stranger.pid)).toBe('mismatched');
200
- fs.writeFileSync(getLockPath(4214), String(stranger.pid));
127
+ const stranger = await harness.livePid();
128
+ expect(getProcessState(stranger)).toBe('mismatched');
129
+ fs.writeFileSync(getLockPath(4214), String(stranger));
201
130
 
202
131
  expect(track(await acquirePortLock(4214, 0))).not.toBeNull();
203
132
  });
204
133
 
205
134
  testPosix('refuses a lock held by a live Kabmo process', async () => {
206
135
  usePidFileFallback();
207
- const holder = await spawnChamberHolder();
136
+ const holder = await harness.spawnChamberHolder();
208
137
  const lockPath = getLockPath(4215);
209
138
  fs.writeFileSync(lockPath, String(holder.pid));
210
139
 
@@ -213,76 +142,3 @@ describe('acquirePortLock', () => {
213
142
  expect(fs.readFileSync(lockPath, 'utf8')).toBe(String(holder.pid));
214
143
  });
215
144
  });
216
-
217
- describe('claimPort', () => {
218
- test('binds a free port through the injected listen', async () => {
219
- const port = await freePort();
220
- let binds = 0;
221
-
222
- await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } });
223
-
224
- expect(binds).toBe(1);
225
- });
226
-
227
- test('re-binds when the occupant is this very process', async () => {
228
- // `bun run --hot` re-evaluates the entry inside the running process, so the
229
- // port is held by us and Bun swaps the handler on the next listen. Killing
230
- // the dev loop on every save is the failure this prevents.
231
- const port = occupy(() => Response.json({ service: 'kabmo', pid: process.pid }));
232
- let binds = 0;
233
-
234
- await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } });
235
-
236
- expect(binds).toBe(1);
237
- });
238
-
239
- testPosix('names a Kabmo occupant held by another process', async () => {
240
- const holder = await spawnChamberHolder();
241
- const port = occupy(() => Response.json({ service: 'kabmo', pid: holder.pid, version: '9.9.9' }));
242
- let binds = 0;
243
-
244
- const error = await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } })
245
- .then(() => null, (thrown: unknown) => thrown);
246
-
247
- expect(error).toBeInstanceOf(PortInUseError);
248
- const message = (error as Error).message;
249
- expect(message).toContain(`Port ${port} is already served by Kabmo`);
250
- expect(message).toContain(`pid ${holder.pid}`);
251
- expect(message).toContain('v9.9.9');
252
- expect(message).toContain('Nothing was stopped');
253
- // A starting instance never binds over an identified one.
254
- expect(binds).toBe(0);
255
- });
256
-
257
- test('names a foreign listener as not Kabmo, without a bind error', async () => {
258
- // Nothing answers `/api/health` and nothing identifies itself, so the probe
259
- // fails first and `listen` is never attempted.
260
- const port = occupy(() => new Response('not kabmo'));
261
- let binds = 0;
262
-
263
- const error = await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } })
264
- .then(() => null, (thrown: unknown) => thrown);
265
-
266
- expect(error).toBeInstanceOf(PortInUseError);
267
- const message = (error as Error).message;
268
- expect(message).toContain(`Port ${port} is in use by another process`);
269
- expect(message).toContain(`lsof -nP -iTCP:${port} -sTCP:LISTEN`);
270
- expect(message).not.toContain('Bind error');
271
- expect(binds).toBe(0);
272
- });
273
-
274
- test('carries the bind error when the port looked free', async () => {
275
- // The probe is only a short-circuit; when `listen` fails anyway, the real
276
- // reason must survive into the error rather than being replaced by a guess.
277
- const port = await freePort();
278
-
279
- const error = await claimPort({
280
- port,
281
- host: '127.0.0.1',
282
- listen: async () => { throw new Error('EADDRINUSE: address already in use'); },
283
- }).then(() => null, (thrown: unknown) => thrown);
284
-
285
- expect(error).toBeInstanceOf(PortInUseError);
286
- expect((error as Error).message).toContain('Bind error: EADDRINUSE: address already in use');
287
- });
288
- });
@@ -18,6 +18,12 @@
18
18
  * receive connections. The newcomer would look healthy in its own log while the
19
19
  * browser kept talking to the old build; the guard stops that state from being
20
20
  * created in the first place.
21
+ *
22
+ * "One port" is every ADDRESS the bind host stands for. A name is not one
23
+ * socket: `localhost` is `127.0.0.1` and `::1`, and Bun binds the one it
24
+ * resolves first — so asking about that family alone let a stale instance keep
25
+ * the other, and the browser landed on whichever it resolved. See
26
+ * `probeAddresses`; the failure message names the address that is held.
21
27
  */
22
28
 
23
29
  import fs from 'fs';
@@ -26,7 +32,7 @@ import { flockAvailable, tryFlock } from '@/server/lib/lifecycle/flock';
26
32
  import { getProcessState } from '@/server/lib/lifecycle/identity';
27
33
  import { describeOccupant, findOccupant } from '@/server/lib/lifecycle/occupant';
28
34
  import { getLockPath } from '@/server/lib/lifecycle/paths';
29
- import { isPortAvailable } from '@/server/lib/lifecycle/probe';
35
+ import { probeAddresses, unavailableAddresses } from '@/server/lib/lifecycle/probe';
30
36
 
31
37
  const LOCK_WAIT_MS = 3_000;
32
38
  /** Grace period for a port that is settling (a server that just exited, or one that is starting). */
@@ -129,7 +135,13 @@ export async function claimPort({ port, host, listen }: ClaimPortOptions): Promi
129
135
  let bindError: unknown = null;
130
136
 
131
137
  for (;;) {
132
- if (await isPortAvailable(port, host)) {
138
+ // Every address the host can land on, not just the first one it resolves
139
+ // to. `localhost` is two sockets, Bun binds one of them, and asking about
140
+ // only that one let a stale instance keep the other half of the port — two
141
+ // servers, one port, and a browser that reached whichever family it
142
+ // happened to resolve. See `probeAddresses`.
143
+ const blocked = await unavailableAddresses(port, host);
144
+ if (blocked.length === 0) {
133
145
  try {
134
146
  await listen();
135
147
  return;
@@ -148,9 +160,9 @@ export async function claimPort({ port, host, listen }: ClaimPortOptions): Promi
148
160
  return;
149
161
  }
150
162
 
151
- if (occupant) throw await buildPortInUseError(port, host, bindError);
163
+ if (occupant) throw await buildPortInUseError(port, host, bindError, blocked);
152
164
 
153
- if (Date.now() >= deadline) throw await buildPortInUseError(port, host, bindError);
165
+ if (Date.now() >= deadline) throw await buildPortInUseError(port, host, bindError, blocked);
154
166
  await Bun.sleep(200);
155
167
  }
156
168
  }
@@ -164,13 +176,33 @@ function portGuidance(): string {
164
176
  ].join('\n');
165
177
  }
166
178
 
167
- async function buildPortInUseError(port: number, host: string, bindError: unknown): Promise<PortInUseError> {
179
+ /**
180
+ * The address clause, when the host is a name that stands for more than one.
181
+ *
182
+ * `Port 3000 is in use` is not actionable on a machine where `localhost` is two
183
+ * sockets: it names neither the one that is taken nor the one that is free, and
184
+ * `lsof` lists both. Naming it is the whole difference between "something is
185
+ * wrong" and "this address is held, that one is not".
186
+ */
187
+ function addressClause(host: string, blocked: string[]): string {
188
+ if (blocked.length === 0) return '';
189
+ if (probeAddresses(host).length < 2) return '';
190
+ return ` on ${blocked.join(', ')}`;
191
+ }
192
+
193
+ async function buildPortInUseError(
194
+ port: number,
195
+ host: string,
196
+ bindError: unknown,
197
+ blocked: string[],
198
+ ): Promise<PortInUseError> {
168
199
  const occupant = await findOccupant(port, host);
169
200
  const bindDetail = bindError instanceof Error ? `\n Bind error: ${bindError.message}` : '';
201
+ const address = addressClause(host, blocked);
170
202
 
171
203
  if (occupant) {
172
204
  return new PortInUseError(
173
- `Port ${port} is already served by Kabmo ${describeOccupant(occupant)}.\n`
205
+ `Port ${port} is already served by Kabmo ${describeOccupant(occupant)}${address}.\n`
174
206
  + ` Nothing was stopped — a starting instance never stops another one.\n`
175
207
  + ` Stop it first: kabmo stop --port ${port}\n`
176
208
  + ` Or start on another port:\n${portGuidance()}`,
@@ -178,7 +210,7 @@ async function buildPortInUseError(port: number, host: string, bindError: unknow
178
210
  }
179
211
 
180
212
  return new PortInUseError(
181
- `Port ${port} is in use by another process (no Kabmo instance answered on it).${bindDetail}\n`
213
+ `Port ${port} is in use by another process${address} (no Kabmo instance answered on it).${bindDetail}\n`
182
214
  + ` Find it with: lsof -nP -iTCP:${port} -sTCP:LISTEN\n`
183
215
  + ` Start on another port:\n${portGuidance()}`,
184
216
  );
@@ -0,0 +1,166 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The IDENTIFICATION half of `probe.ts`: `GET /api/health`, which is how every
8
+ * CLI command and the port guard find out who is on a port.
9
+ *
10
+ * The cases run against real HTTP stubs on loopback, because the questions are
11
+ * about a real exchange — a status code, a body's shape, a scheme, and which
12
+ * ADDRESS the request was aimed at. That last one is the reported bug: a
13
+ * single-address request for a name that stands for two sockets reported a live
14
+ * instance as absent, so `status` and `stop` saw nothing while a browser kept
15
+ * landing on it.
16
+ */
17
+
18
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
19
+
20
+ import { fetchHealth } from '@/server/lib/lifecycle/probe';
21
+ import { createLoopbackHarness, ipv6Loopback, realFetch } from '@/test-support/loopback';
22
+
23
+ const harness = createLoopbackHarness();
24
+ const { occupy, occupyAt, freePort, freePortBothFamilies } = harness;
25
+
26
+ /**
27
+ * Another file in the same `bun test` process may have left a stub installed on
28
+ * the global, and every case here talks to a REAL listener it just started on
29
+ * loopback — so the global is put back before each one rather than trusting the
30
+ * ambient one.
31
+ */
32
+ beforeEach(() => {
33
+ globalThis.fetch = realFetch;
34
+ });
35
+
36
+ afterEach(async () => {
37
+ await harness.dispose();
38
+ });
39
+
40
+ afterAll(async () => {
41
+ await harness.dispose();
42
+ });
43
+
44
+ describe('fetchHealth', () => {
45
+ test('returns the payload of a Kabmo listener, keeping only known fields', async () => {
46
+ const full = occupy(() => Response.json({
47
+ service: 'kabmo',
48
+ pid: 4242,
49
+ version: '3.10.1',
50
+ startedAt: '2026-01-01T00:00:00.000Z',
51
+ uptime: 12.5,
52
+ mock: false,
53
+ runtime: 'bun',
54
+ mode: 'dev',
55
+ port: 1234,
56
+ host: '127.0.0.1',
57
+ tls: false,
58
+ tray: true,
59
+ authEnabled: true,
60
+ fds: { open: 3352, highest: 3351, limit: 61_440, nearCliff: false },
61
+ extra: 'dropped',
62
+ }));
63
+
64
+ const health = await fetchHealth(full.port, '127.0.0.1', 1000, false);
65
+
66
+ expect(health).toEqual({
67
+ service: 'kabmo',
68
+ pid: 4242,
69
+ version: '3.10.1',
70
+ startedAt: '2026-01-01T00:00:00.000Z',
71
+ uptime: 12.5,
72
+ mock: false,
73
+ runtime: 'bun',
74
+ mode: 'dev',
75
+ port: 1234,
76
+ host: '127.0.0.1',
77
+ tls: false,
78
+ tray: true,
79
+ authEnabled: true,
80
+ // Carried, not dropped: `status` prints this as its warning line, and
81
+ // whitelisting it away left the report without one.
82
+ fds: { open: 3352, highest: 3351, limit: 61_440, nearCliff: false },
83
+ });
84
+ });
85
+
86
+ test('reports a malformed fds field as null rather than a half-trusted object', async () => {
87
+ const partial = occupy(() => Response.json({
88
+ service: 'kabmo',
89
+ fds: { open: 1, highest: 1, limit: 2 },
90
+ }));
91
+
92
+ const health = await fetchHealth(partial.port, '127.0.0.1', 1000, false);
93
+
94
+ expect(health?.service).toBe('kabmo');
95
+ expect(health?.fds).toBeNull();
96
+ });
97
+
98
+ test('drops fields whose type is wrong rather than passing them on', async () => {
99
+ const mistyped = occupy(() => Response.json({ service: 'kabmo', pid: '4242', version: 7, tls: 'yes' }));
100
+
101
+ const health = await fetchHealth(mistyped.port, '127.0.0.1', 1000, false);
102
+
103
+ expect(health?.service).toBe('kabmo');
104
+ expect(health?.pid).toBeUndefined();
105
+ expect(health?.version).toBeUndefined();
106
+ expect(health?.tls).toBeUndefined();
107
+ });
108
+
109
+ test.skipIf(!ipv6Loopback)('finds a listener on either address of a name', async () => {
110
+ // `localhost` is two sockets and a server binds one. Asking only the family
111
+ // Bun happens to pick left the other instance undiscoverable: `kabmo status`
112
+ // and `kabmo stop` reported nothing while the browser kept reaching it.
113
+ const port = await freePortBothFamilies();
114
+ const health = () => Response.json({ service: 'kabmo', pid: process.pid });
115
+
116
+ const v4 = occupyAt(port, '127.0.0.1', health);
117
+ expect((await fetchHealth(port, 'localhost', 1000, false))?.service).toBe('kabmo');
118
+ v4.stop(true);
119
+
120
+ const v6 = occupyAt(port, '::1', health);
121
+ expect((await fetchHealth(port, 'localhost', 1000, false))?.service).toBe('kabmo');
122
+ v6.stop(true);
123
+ });
124
+
125
+ test('recognizes a pre-rename listener, and reports it under the current name', async () => {
126
+ // A server started before the rename is still running the old build: its
127
+ // health payload says `ompchamber`, and `status`/`stop` must still find it.
128
+ const legacy = occupy(() => Response.json({ service: 'ompchamber', pid: 4242, port: 3000 }));
129
+
130
+ const health = await fetchHealth(legacy.port, '127.0.0.1', 1000, false);
131
+
132
+ expect(health?.service).toBe('kabmo');
133
+ expect(health?.pid).toBe(4242);
134
+ });
135
+
136
+ test('ignores a listener that is not Kabmo', async () => {
137
+ const foreign = occupy(() => Response.json({ service: 'something-else', pid: process.pid }));
138
+ expect(await fetchHealth(foreign.port, '127.0.0.1', 1000, false)).toBeNull();
139
+ });
140
+
141
+ test('ignores error responses, non-JSON bodies, and non-objects', async () => {
142
+ const broken = occupy(() => new Response('nope', { status: 500 }));
143
+ expect(await fetchHealth(broken.port, '127.0.0.1', 1000, false)).toBeNull();
144
+
145
+ const html = occupy(() => new Response('<html>not json</html>', { headers: { 'content-type': 'text/html' } }));
146
+ expect(await fetchHealth(html.port, '127.0.0.1', 1000, false)).toBeNull();
147
+
148
+ const list = occupy(() => Response.json([{ service: 'kabmo' }]));
149
+ expect(await fetchHealth(list.port, '127.0.0.1', 1000, false)).toBeNull();
150
+ });
151
+
152
+ test('returns null for a port nobody listens on, and for a bad port', async () => {
153
+ expect(await fetchHealth(await freePort(), '127.0.0.1', 500)).toBeNull();
154
+ expect(await fetchHealth(0, '127.0.0.1', 500)).toBeNull();
155
+ expect(await fetchHealth(Number.NaN, '127.0.0.1', 500)).toBeNull();
156
+ });
157
+
158
+ test('a plaintext listener is not found over TLS, and the mode decides the scheme', async () => {
159
+ const plain = occupy(() => Response.json({ service: 'kabmo', pid: process.pid }));
160
+
161
+ expect(await fetchHealth(plain.port, '127.0.0.1', 1000, true)).toBeNull();
162
+ expect(await fetchHealth(plain.port, '127.0.0.1', 1000, false)).not.toBeNull();
163
+ // 'auto' cannot know which scheme a live instance chose, so it tries both.
164
+ expect(await fetchHealth(plain.port, '127.0.0.1', 1000)).not.toBeNull();
165
+ });
166
+ });