@kabmo-labs/kabmo 0.7.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.
- package/README.md +7 -0
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +403 -384
- package/dist/client/static/js/{index-0wvbca0b.js → index-bpptfkz2.js} +905 -875
- package/dist/client/static/js/index-k315p1e8.css +1 -0
- package/package.json +10 -1
- package/src/client/components/common/code-surface/documents.test.ts +120 -0
- package/src/client/components/common/code-surface/documents.ts +201 -0
- package/src/client/components/common/code-surface/extensions.test.ts +110 -0
- package/src/client/components/common/code-surface/extensions.ts +162 -0
- package/src/client/components/common/code-surface/handle.ts +102 -0
- package/src/client/components/common/code-surface/index.tsx +253 -314
- package/src/client/components/common/code-surface/keymap.test.ts +133 -0
- package/src/client/components/common/code-surface/keymap.ts +267 -0
- package/src/client/components/common/code-surface/marks.ts +74 -0
- package/src/client/components/common/code-surface/shiki.ts +234 -0
- package/src/client/components/common/code-surface/sync.test.ts +185 -0
- package/src/client/components/common/code-surface/sync.ts +58 -0
- package/src/client/components/common/code-surface/theme.test.ts +202 -0
- package/src/client/components/common/code-surface/theme.ts +111 -0
- package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
- package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
- package/src/client/components/common/header-controls/index.tsx +24 -1
- package/src/client/components/common/header-controls/shell-chrome.test.tsx +272 -0
- package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
- package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
- package/src/client/components/common/shell-update-button/index.tsx +125 -0
- package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
- package/src/client/components/common/toolbar-row/index.tsx +189 -0
- package/src/client/components/common/workspace-options-menu/index.tsx +19 -1
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -6
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
- package/src/client/components/layout/desktop-layout/index.tsx +55 -69
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +24 -0
- package/src/client/components/layout/session-sidebar/CategoryHeader.worktree.test.tsx +126 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +4 -0
- package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +12 -0
- package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
- package/src/client/components/layout/session-sidebar/SessionsView.tsx +24 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.test.ts +48 -0
- package/src/client/components/layout/session-sidebar/WorktreeBadge.tsx +55 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +23 -16
- package/src/client/components/mobile/RightSidebar.tsx +21 -10
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/header-titlebar.test.tsx +172 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +10 -1
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
- package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -2
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +16 -0
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +4 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +5 -0
- package/src/client/components/mobile/mobile-session-sidebar/WorktreeDialog.tsx +49 -0
- package/src/client/components/mobile/worktree-session.ts +51 -0
- package/src/client/components/settings/Modal.tsx +8 -0
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/ProjectSettings.tsx +22 -8
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +146 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.test.ts +83 -0
- package/src/client/components/settings/categories/project-settings/worktree-row.ts +47 -0
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +36 -55
- package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +8 -0
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
- package/src/client/components/workspace/chat-timeline/index.tsx +5 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.test.tsx +146 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +32 -17
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
- package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
- package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
- package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
- package/src/client/components/workspace/diff-panel/index.tsx +76 -145
- package/src/client/components/workspace/diff-panel/merge.ts +315 -0
- package/src/client/components/workspace/editor/Body.tsx +4 -7
- package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
- package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
- package/src/client/components/workspace/editor/FilePane.tsx +4 -7
- package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
- package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
- package/src/client/components/workspace/editor/index.tsx +7 -9
- package/src/client/components/workspace/file-explorer/ContextMenu.tsx +2 -1
- package/src/client/components/workspace/file-explorer/Modals.tsx +40 -12
- package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
- package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
- package/src/client/components/workspace/file-explorer/RootMenu.tsx +14 -5
- package/src/client/components/workspace/file-explorer/Toolbar.tsx +74 -0
- package/src/client/components/workspace/file-explorer/TreeItem.tsx +38 -3
- package/src/client/components/workspace/file-explorer/filter.ts +47 -0
- package/src/client/components/workspace/file-explorer/index.tsx +77 -64
- package/src/client/components/workspace/file-explorer/local-edit.test.ts +135 -0
- package/src/client/components/workspace/search-panel/Body.tsx +1 -1
- package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
- package/src/client/components/workspace/worktree-modal/index.tsx +261 -0
- package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
- package/src/client/hooks/chat/omp/reconnect.ts +187 -0
- package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
- package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
- package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
- package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
- package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
- package/src/client/hooks/chat/omp/session-list.tsx +69 -9
- package/src/client/hooks/chat/timeline/generating.ts +19 -2
- package/src/client/hooks/chat/timeline/index.ts +27 -28
- package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
- package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
- package/src/client/hooks/chat/timeline/send-execute.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send-steer.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.test.ts +2 -0
- package/src/client/hooks/chat/timeline/send.ts +11 -1
- package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
- package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
- package/src/client/hooks/chat/timeline/session-load.ts +69 -37
- package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
- package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
- package/src/client/hooks/chat/timeline/spawn-target.ts +52 -0
- package/src/client/hooks/chat/timeline/tool-open.test.ts +74 -0
- package/src/client/hooks/chat/timeline/tool-open.ts +52 -8
- package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
- package/src/client/hooks/code/deferred-highlight.ts +129 -0
- package/src/client/hooks/editor/find-state.ts +1 -1
- package/src/client/hooks/editor/theme.test.ts +206 -0
- package/src/client/hooks/editor/theme.ts +82 -0
- package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
- package/src/client/hooks/editor/use-editor-drop.ts +67 -14
- package/src/client/hooks/editor/use-editor-find.ts +7 -7
- package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
- package/src/client/hooks/editor/use-outline-view.ts +94 -7
- package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
- package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
- package/src/client/hooks/editor/use-search-reveal.ts +1 -1
- package/src/client/hooks/ui/long-press.test.ts +174 -0
- package/src/client/hooks/ui/long-press.ts +112 -0
- package/src/client/hooks/ui/settings-modal.ts +56 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +75 -0
- package/src/client/hooks/ui/updates/shell-download.ts +153 -28
- package/src/client/hooks/ui/updates/shell-offer.test.ts +213 -0
- package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
- package/src/client/hooks/ui/updates/shell-update.ts +119 -0
- package/src/client/hooks/workspace/diff-file.ts +29 -10
- package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
- package/src/client/hooks/workspace/editor-view.ts +61 -0
- package/src/client/hooks/workspace/file-create.ts +41 -11
- package/src/client/hooks/workspace/file-listing.ts +56 -1
- package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/file-tree-actions.ts +37 -5
- package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
- package/src/client/hooks/workspace/worktree/index.ts +165 -0
- package/src/client/lib/desktop/bridge.ts +45 -0
- package/src/client/lib/search-reveal.ts +1 -1
- package/src/client/main.tsx +5 -1
- package/src/client/tailwind.css +56 -8
- package/src/server/index.ts +7 -2
- package/src/server/lib/auth/guard.test.ts +28 -0
- package/src/server/lib/auth/guard.ts +10 -0
- package/src/server/lib/bundler/titlebar-css.test.ts +42 -1
- package/src/server/lib/db/schema.ts +4 -0
- package/src/server/lib/db/worktree-registry.server.ts +91 -0
- package/src/server/lib/fs/canonical.ts +52 -0
- package/src/server/lib/fs/relative-path.ts +48 -0
- package/src/server/lib/fs/root-worktree.test.ts +116 -0
- package/src/server/lib/fs/root.ts +39 -10
- package/src/server/lib/fs/worktree/carry.ts +134 -0
- package/src/server/lib/fs/worktree/create.test.ts +249 -0
- package/src/server/lib/fs/worktree/create.ts +262 -0
- package/src/server/lib/fs/worktree/errors.ts +43 -0
- package/src/server/lib/fs/worktree/list.test.ts +207 -0
- package/src/server/lib/fs/worktree/list.ts +142 -0
- package/src/server/lib/fs/worktree/location.test.ts +206 -0
- package/src/server/lib/fs/worktree/location.ts +114 -0
- package/src/server/lib/fs/worktree/project-id.ts +53 -0
- package/src/server/lib/fs/worktree/remove.test.ts +242 -0
- package/src/server/lib/fs/worktree/remove.ts +152 -0
- package/src/server/lib/fs/worktree/settings.server.ts +56 -0
- package/src/server/lib/fs/worktree/topology.ts +142 -0
- package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
- package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
- package/src/server/lib/lifecycle/port-guard.ts +39 -7
- package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
- package/src/server/lib/lifecycle/probe.test.ts +77 -164
- package/src/server/lib/lifecycle/probe.ts +121 -13
- package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
- package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
- package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
- package/src/server/lib/omp/session/sidebar-data.canonical.test.ts +155 -0
- package/src/server/lib/omp/session/sidebar-data.server.ts +28 -1
- package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
- package/src/server/lib/omp/session/telemetry.test.ts +33 -0
- package/src/server/lib/panels/local-edits.server.ts +33 -0
- package/src/server/lib/panels/sync.fixture.test-util.ts +92 -0
- package/src/server/lib/panels/sync.local-edits.test.ts +150 -0
- package/src/server/lib/panels/sync.server.test.ts +125 -147
- package/src/server/lib/panels/sync.server.ts +153 -48
- package/src/server/lib/realtime/signals.server.ts +10 -0
- package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
- package/src/server/lib/realtime/topics.server.ts +16 -0
- package/src/server/lib/updates/shell-assets.test.ts +114 -0
- package/src/server/lib/updates/shell-assets.ts +200 -0
- package/src/server/lib/updates/shell-cache.ts +105 -0
- package/src/server/lib/updates/shell-pages.test.ts +187 -0
- package/src/server/lib/updates/shell-pages.ts +157 -0
- package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
- package/src/server/lib/updates/shell-release.test.ts +59 -71
- package/src/server/lib/updates/shell-release.ts +178 -198
- package/src/server/routes/fs/action-create.test.ts +18 -13
- package/src/server/routes/fs/action-nested.test.ts +184 -0
- package/src/server/routes/fs/action.ts +47 -16
- package/src/server/routes/fs/git-merge.test.ts +11 -1
- package/src/server/routes/fs/read.test.ts +24 -7
- package/src/server/routes/fs/scope.test.ts +15 -10
- package/src/server/routes/fs/scope.ts +3 -14
- package/src/server/routes/index.ts +2 -0
- package/src/server/routes/updates/android.ts +6 -1
- package/src/server/routes/updates/desktop.ts +59 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/server/routes/updates/ios.ts +5 -1
- package/src/server/routes/worktrees/create.ts +138 -0
- package/src/server/routes/worktrees/index.ts +25 -0
- package/src/server/routes/worktrees/list.ts +134 -0
- package/src/server/routes/worktrees/remove.ts +102 -0
- package/src/server/routes/worktrees/routes.test.ts +326 -0
- package/src/server/routes/worktrees/shared.ts +90 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
- package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
- package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
- package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
- package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
- package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
- package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
- package/src/shared/lib/code/editor/find-scan.ts +177 -0
- package/src/shared/lib/code/editor/keymap.ts +3 -3
- package/src/shared/lib/code/editor/lines.test.ts +7 -55
- package/src/shared/lib/code/editor/lines.ts +8 -48
- package/src/shared/lib/code/editor/marks.test.ts +4 -5
- package/src/shared/lib/code/editor/marks.ts +13 -25
- package/src/shared/lib/code/editor/theme-loader.ts +97 -0
- package/src/shared/lib/code/editor/themes.generated.ts +156 -0
- package/src/shared/lib/code/editor/themes.test.ts +165 -0
- package/src/shared/lib/code/editor/themes.ts +165 -0
- package/src/shared/lib/code/highlight-memo.test.ts +103 -0
- package/src/shared/lib/code/highlight-memo.ts +149 -0
- package/src/shared/lib/code/highlighter-warmup.ts +112 -0
- package/src/shared/lib/code/highlighter.ts +3 -1
- package/src/shared/lib/code/idle.ts +37 -0
- package/src/shared/lib/code/outline/rows.test.ts +96 -0
- package/src/shared/lib/code/outline/rows.ts +94 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
- package/src/shared/lib/code/syntax-highlight.ts +2 -3
- package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
- package/src/shared/lib/fs/file-kind.ts +47 -13
- package/src/shared/lib/fs/file-tree.test.ts +183 -0
- package/src/shared/lib/fs/file-tree.ts +159 -0
- package/src/shared/lib/fs/file-url.test.ts +52 -0
- package/src/shared/lib/fs/file-url.ts +37 -0
- package/src/shared/lib/fs/patch-sides.test.ts +114 -0
- package/src/shared/lib/fs/patch-sides.ts +139 -0
- package/src/shared/lib/fs/paths.test.ts +24 -1
- package/src/shared/lib/fs/paths.ts +17 -0
- package/src/shared/lib/fs/worktree/settings-keys.ts +18 -0
- package/src/shared/lib/omp/session/prewarm.ts +7 -0
- package/src/shared/lib/realtime/protocol.ts +16 -0
- package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
- package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
- package/src/shared/lib/updates/shell-arch.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +24 -4
- package/src/shared/lib/workspace/active-project.ts +23 -0
- package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
- package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
- package/src/shared/lib/workspace/panel-widths.ts +25 -5
- package/src/shared/lib/workspace/session-state/cache.ts +159 -0
- package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
- package/src/shared/lib/workspace/session-state/merge.ts +100 -0
- package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
- package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
- package/src/shared/lib/workspace/session-state/store.ts +92 -130
- package/src/shared/lib/workspace/workspace-modules.test.ts +49 -1
- package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
- package/src/shared/lib/workspace/worktree/branch-name.test.ts +95 -0
- package/src/shared/lib/workspace/worktree/branch-name.ts +115 -0
- package/src/shared/lib/workspace/worktree/types.ts +104 -0
- package/src/shared/types/fs.ts +10 -0
- package/src/shared/types/realtime.ts +29 -0
- package/src/shared/types/settings/state.ts +10 -0
- package/src/shared/types/workspace.ts +14 -0
- package/src/test-support/file-explorer-disk.test-util.ts +76 -0
- package/src/test-support/file-explorer.test-util.ts +71 -40
- package/src/test-support/loopback.ts +183 -0
- package/dist/client/static/js/index-nzyjf92m.css +0 -1
- package/src/client/components/common/code-editor/handler.test.ts +0 -274
- package/src/client/components/common/code-editor/handler.ts +0 -175
- package/src/client/components/common/code-editor/index.tsx +0 -314
- package/src/client/components/common/code-surface/measure.ts +0 -148
- package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
- package/src/client/hooks/editor/scroll-container.test.ts +0 -102
- package/src/client/hooks/editor/scroll-container.ts +0 -26
- package/src/client/hooks/editor/use-code-window.ts +0 -100
- package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
- package/src/client/hooks/editor/use-editor-history.ts +0 -134
- package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
- package/src/client/hooks/editor/use-occurrences.ts +0 -98
- package/src/shared/lib/code/editor/commands.test.ts +0 -155
- package/src/shared/lib/code/editor/commands.ts +0 -260
- package/src/shared/lib/code/editor/edits.test.ts +0 -100
- package/src/shared/lib/code/editor/edits.ts +0 -122
- package/src/shared/lib/code/editor/history.test.ts +0 -118
- package/src/shared/lib/code/editor/history.ts +0 -101
- package/src/shared/lib/code/editor/layers.test.ts +0 -77
- package/src/shared/lib/code/editor/layers.ts +0 -67
- package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
- package/src/shared/lib/code/editor/line-edits.ts +0 -159
- package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
- package/src/shared/lib/code/editor/occurrences.ts +0 -111
- package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
- package/src/shared/lib/code/editor/reset-css.ts +0 -32
- package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
- package/src/shared/lib/code/editor/text-commands.ts +0 -154
- package/src/shared/lib/code/lazy-window.test.ts +0 -161
- package/src/shared/lib/code/lazy-window.ts +0 -156
- package/src/shared/lib/code/windowed-highlight.ts +0 -114
- package/src/shared/lib/fs/diff-parser.test.ts +0 -116
- package/src/shared/lib/fs/diff-parser.ts +0 -213
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `claimPort` — the bind decision, and its whole value is in the failure path:
|
|
8
|
+
* a starter that loses must fail with a message that names the real situation
|
|
9
|
+
* instead of a bare `EADDRINUSE`.
|
|
10
|
+
*
|
|
11
|
+
* The cases drive real listeners on OS-assigned ports, because the question is
|
|
12
|
+
* about sockets. One of them is the reported bug: `localhost` stands for two
|
|
13
|
+
* addresses, the guard asked about only the one Bun would bind, and a stale
|
|
14
|
+
* instance kept the other — two servers on one port, and a browser landing on
|
|
15
|
+
* whichever family it resolved.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import os from 'node:os';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import { testPosix } from '@/test-support/platform';
|
|
23
|
+
|
|
24
|
+
import { claimPort, PortInUseError } from '@/server/lib/lifecycle/port-guard';
|
|
25
|
+
import { createLoopbackHarness, ipv6Loopback, realFetch } from '@/test-support/loopback';
|
|
26
|
+
|
|
27
|
+
const harness = createLoopbackHarness();
|
|
28
|
+
const { occupy, occupyAt, freePort, freePortBothFamilies, livePid, spawnChamberHolder } = harness;
|
|
29
|
+
|
|
30
|
+
let tempDir: string;
|
|
31
|
+
let originalDataDir: string | undefined;
|
|
32
|
+
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
// The instance record and the port lock both live under the data directory;
|
|
35
|
+
// pointing it at a temp dir keeps a test from reading (or writing) the real
|
|
36
|
+
// `~/.kabmo/run`.
|
|
37
|
+
originalDataDir = Bun.env.KABMO_DATA_DIR;
|
|
38
|
+
tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kabmo-test-claim-port-'));
|
|
39
|
+
Bun.env.KABMO_DATA_DIR = tempDir;
|
|
40
|
+
globalThis.fetch = realFetch;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(async () => {
|
|
44
|
+
await harness.dispose();
|
|
45
|
+
if (originalDataDir === undefined) delete Bun.env.KABMO_DATA_DIR;
|
|
46
|
+
else Bun.env.KABMO_DATA_DIR = originalDataDir;
|
|
47
|
+
fs.rmSync(tempDir, { recursive: true, force: true });
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
describe('claimPort', () => {
|
|
51
|
+
test('binds a free port through the injected listen', async () => {
|
|
52
|
+
const port = await freePort();
|
|
53
|
+
let binds = 0;
|
|
54
|
+
|
|
55
|
+
await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } });
|
|
56
|
+
|
|
57
|
+
expect(binds).toBe(1);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test('re-binds when the occupant is this very process', async () => {
|
|
61
|
+
// `bun run --hot` re-evaluates the entry inside the running process, so the
|
|
62
|
+
// port is held by us and Bun swaps the handler on the next listen. Killing
|
|
63
|
+
// the dev loop on every save is the failure this prevents.
|
|
64
|
+
const { port } = occupy(() => Response.json({ service: 'kabmo', pid: process.pid }));
|
|
65
|
+
let binds = 0;
|
|
66
|
+
|
|
67
|
+
await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } });
|
|
68
|
+
|
|
69
|
+
expect(binds).toBe(1);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
testPosix('names a Kabmo occupant held by another process', async () => {
|
|
73
|
+
const holder = await spawnChamberHolder();
|
|
74
|
+
const { port } = occupy(() => Response.json({ service: 'kabmo', pid: holder.pid, version: '9.9.9' }));
|
|
75
|
+
let binds = 0;
|
|
76
|
+
|
|
77
|
+
const error = await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } })
|
|
78
|
+
.then(() => null, (thrown: unknown) => thrown);
|
|
79
|
+
|
|
80
|
+
expect(error).toBeInstanceOf(PortInUseError);
|
|
81
|
+
const message = (error as Error).message;
|
|
82
|
+
expect(message).toContain(`Port ${port} is already served by Kabmo`);
|
|
83
|
+
expect(message).toContain(`pid ${holder.pid}`);
|
|
84
|
+
expect(message).toContain('v9.9.9');
|
|
85
|
+
expect(message).toContain('Nothing was stopped');
|
|
86
|
+
// A starting instance never binds over an identified one.
|
|
87
|
+
expect(binds).toBe(0);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('names a foreign listener as not Kabmo, without a bind error', async () => {
|
|
91
|
+
// Nothing answers `/api/health` and nothing identifies itself, so the probe
|
|
92
|
+
// fails first and `listen` is never attempted.
|
|
93
|
+
const { port } = occupy(() => new Response('not kabmo'));
|
|
94
|
+
let binds = 0;
|
|
95
|
+
|
|
96
|
+
const error = await claimPort({ port, host: '127.0.0.1', listen: async () => { binds += 1; } })
|
|
97
|
+
.then(() => null, (thrown: unknown) => thrown);
|
|
98
|
+
|
|
99
|
+
expect(error).toBeInstanceOf(PortInUseError);
|
|
100
|
+
const message = (error as Error).message;
|
|
101
|
+
expect(message).toContain(`Port ${port} is in use by another process`);
|
|
102
|
+
expect(message).toContain(`lsof -nP -iTCP:${port} -sTCP:LISTEN`);
|
|
103
|
+
expect(message).not.toContain('Bind error');
|
|
104
|
+
expect(binds).toBe(0);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test.skipIf(!ipv6Loopback)('refuses a name whose OTHER address is held', async () => {
|
|
108
|
+
// The reported bug: a stale instance held `127.0.0.1:3000`, `bun run dev`
|
|
109
|
+
// probed only `localhost`'s other family (`::1`), found it free, and bound
|
|
110
|
+
// it. Two servers, one port, and a browser landing on whichever family it
|
|
111
|
+
// resolved — the stale one, serving a different data mode.
|
|
112
|
+
const port = await freePortBothFamilies();
|
|
113
|
+
// A listener reporting OUR pid is read as this process re-binding, so the
|
|
114
|
+
// occupant has to name another live process to be refused.
|
|
115
|
+
const holder = await livePid();
|
|
116
|
+
const held = occupyAt(port, '127.0.0.1', () => Response.json({ service: 'kabmo', pid: holder }));
|
|
117
|
+
let binds = 0;
|
|
118
|
+
|
|
119
|
+
const error = await claimPort({ port, host: 'localhost', listen: async () => { binds += 1; } })
|
|
120
|
+
.then(() => null, (thrown: unknown) => thrown);
|
|
121
|
+
|
|
122
|
+
expect(error).toBeInstanceOf(PortInUseError);
|
|
123
|
+
const message = (error as Error).message;
|
|
124
|
+
expect(message).toContain(`Port ${port} is already served by Kabmo`);
|
|
125
|
+
// The address clause is the actionable half: `lsof` lists both sockets, so
|
|
126
|
+
// a bare "port busy" does not say which one has to be freed.
|
|
127
|
+
expect(message).toContain('on 127.0.0.1');
|
|
128
|
+
expect(binds).toBe(0);
|
|
129
|
+
|
|
130
|
+
held.stop(true);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test.skipIf(!ipv6Loopback)('binds a name whose addresses are both free', async () => {
|
|
134
|
+
const port = await freePortBothFamilies();
|
|
135
|
+
let binds = 0;
|
|
136
|
+
|
|
137
|
+
await claimPort({ port, host: 'localhost', listen: async () => { binds += 1; } });
|
|
138
|
+
|
|
139
|
+
expect(binds).toBe(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('carries the bind error when the port looked free', async () => {
|
|
143
|
+
// The probe is only a short-circuit; when `listen` fails anyway, the real
|
|
144
|
+
// reason must survive into the error rather than being replaced by a guess.
|
|
145
|
+
const port = await freePort();
|
|
146
|
+
|
|
147
|
+
const error = await claimPort({
|
|
148
|
+
port,
|
|
149
|
+
host: '127.0.0.1',
|
|
150
|
+
listen: async () => { throw new Error('EADDRINUSE: address already in use'); },
|
|
151
|
+
}).then(() => null, (thrown: unknown) => thrown);
|
|
152
|
+
|
|
153
|
+
expect(error).toBeInstanceOf(PortInUseError);
|
|
154
|
+
expect((error as Error).message).toContain('Bind error: EADDRINUSE: address already in use');
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -4,22 +4,18 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* `
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
|
28
|
+
import { acquirePortLock } from '@/server/lib/lifecycle/port-guard';
|
|
33
29
|
import type { PortLock } from '@/server/lib/lifecycle/port-guard';
|
|
34
|
-
import {
|
|
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
|
-
//
|
|
126
|
-
//
|
|
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
|
-
|
|
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 =
|
|
198
|
-
|
|
199
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
});
|