@bitkyc08/opencodex 2.67.0 → 2.68.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/bin/ocx.mjs +3 -0
- package/gui/dist/assets/App-DCBismRi.css +1 -0
- package/gui/dist/assets/App-mpsibsFj.js +51 -0
- package/gui/dist/assets/Tray-Bn8WhKBH.js +1 -0
- package/gui/dist/assets/{index-DtNmX7hW.css → index-BDUBS8PW.css} +1 -1
- package/gui/dist/assets/index-kUE8tuAK.js +86 -0
- package/gui/dist/assets/quota-summary-DL-bNuNL.js +1 -0
- package/gui/dist/index.html +21 -2
- package/package.json +1 -1
- package/src/adapters/anthropic-image-guard.ts +13 -1
- package/src/adapters/anthropic-image-normalize.ts +3 -2
- package/src/adapters/anthropic-output-schema.ts +36 -0
- package/src/adapters/anthropic.ts +10 -2
- package/src/adapters/base.ts +7 -0
- package/src/adapters/codebuddy/live-models.ts +180 -0
- package/src/adapters/coding-agent/protocol.ts +91 -12
- package/src/adapters/coding-agent/turn.ts +32 -27
- package/src/adapters/devin/cloud-direct/index.ts +1 -0
- package/src/adapters/devin/cloud-direct/stated-reset-retry.ts +18 -4
- package/src/adapters/devin.ts +9 -6
- package/src/adapters/google-http.ts +4 -4
- package/src/adapters/google.ts +93 -3
- package/src/adapters/kiro/adapter.ts +6 -2
- package/src/adapters/kiro/stream.ts +12 -2
- package/src/adapters/kiro/usage.ts +3 -1
- package/src/adapters/kiro-errors.ts +11 -1
- package/src/adapters/kiro-events.ts +27 -1
- package/src/adapters/kiro-refusal.ts +30 -0
- package/src/adapters/kiro-retry.ts +71 -31
- package/src/adapters/openai-chat/deepseek-artifact-schema.ts +45 -0
- package/src/adapters/openai-chat/messages.ts +31 -4
- package/src/adapters/openai-chat/serialized-tool-call-content.ts +52 -7
- package/src/adapters/openai-chat/tool-call-id-remint.ts +65 -0
- package/src/adapters/openai-chat/tool-schema.ts +6 -2
- package/src/adapters/openai-chat.ts +2 -2
- package/src/adapters/openai-responses/muse-tool-choice.ts +31 -0
- package/src/adapters/openai-responses/passthrough.ts +9 -3
- package/src/adapters/opencode-go-additional-tools.ts +19 -10
- package/src/adapters/physical-send.ts +10 -3
- package/src/adapters/registry.ts +2 -1
- package/src/adapters/responses-tool-schema.ts +31 -3
- package/src/adapters/run-turn-queue.ts +63 -23
- package/src/adapters/unique-tool-call-ids.ts +63 -0
- package/src/adapters/xai-web-search.ts +32 -2
- package/src/bridge/sse.ts +14 -2
- package/src/chat/inbound.ts +20 -6
- package/src/claude/agents-inject.ts +4 -0
- package/src/claude/desktop-3p.ts +53 -13
- package/src/claude/desktop-profile.ts +41 -4
- package/src/claude/inbound-model-options.ts +14 -2
- package/src/claude/inbound.ts +1 -1
- package/src/claude/intercept/connect-proxy.ts +17 -1
- package/src/claude/intercept/local-ca.ts +7 -1
- package/src/claude/message-threads.ts +28 -0
- package/src/cli/account-api.ts +13 -0
- package/src/cli/account-auth.ts +49 -2
- package/src/cli/account-extended.ts +4 -2
- package/src/cli/account.ts +4 -2
- package/src/cli/capabilities.ts +17 -0
- package/src/cli/claude.ts +3 -1
- package/src/cli/dispatch.ts +62 -5
- package/src/cli/index.ts +84 -79
- package/src/cli/minimax.ts +4 -2
- package/src/cli/opencode.ts +6 -1
- package/src/cli/restart-handoff.ts +110 -0
- package/src/cli/status.ts +51 -0
- package/src/client/connect.ts +34 -15
- package/src/client/link-ingress.ts +102 -0
- package/src/client/link-join.ts +37 -16
- package/src/client/link-relay.ts +228 -45
- package/src/client/link-state.ts +54 -0
- package/src/client/link-status.ts +39 -0
- package/src/client/link-teardown.ts +2 -2
- package/src/client/link-tunnel.ts +615 -86
- package/src/client/machine-api.ts +10 -4
- package/src/client/machine-listener.ts +57 -10
- package/src/client/runtime.ts +210 -35
- package/src/clients/config-export/constants.ts +1 -12
- package/src/clients/config-export/contracts.ts +2 -0
- package/src/clients/config-export/model-metadata.ts +9 -3
- package/src/clients/config-export/omp.ts +1 -1
- package/src/clients/config-export/zcode-store.ts +2 -2
- package/src/clients/config-export.ts +36 -3
- package/src/codex/app-server-processes.ts +32 -0
- package/src/codex/app-server-restart-service.ts +20 -3
- package/src/codex/catalog/access-programs.ts +50 -0
- package/src/codex/catalog/build-entries.ts +13 -0
- package/src/codex/catalog/derive-entry.ts +1 -0
- package/src/codex/catalog/model-hints.ts +11 -0
- package/src/codex/catalog/parsing.ts +6 -2
- package/src/codex/catalog/provider-models.ts +81 -0
- package/src/codex/catalog/retained-sync.ts +3 -0
- package/src/codex/codex-write-lock.ts +2 -2
- package/src/codex/convergence.ts +2 -0
- package/src/codex/desired-state.ts +14 -4
- package/src/codex/home.ts +21 -3
- package/src/codex/inject/restore.ts +17 -0
- package/src/codex/inject/routing-classify.ts +3 -2
- package/src/codex/inject-coordination.ts +4 -1
- package/src/codex/inject.ts +8 -1
- package/src/codex/journal.ts +6 -2
- package/src/codex/management-convergence.ts +9 -0
- package/src/codex/model-entitlements.ts +28 -5
- package/src/codex/routing/idle-window.ts +58 -0
- package/src/codex/routing-drift.ts +134 -0
- package/src/codex/routing-healer.ts +419 -0
- package/src/codex/routing.ts +10 -0
- package/src/codex/runtime.ts +188 -57
- package/src/codex/sibling-handoff.ts +71 -0
- package/src/codex/sibling-start.ts +139 -0
- package/src/codex/sync.ts +5 -3
- package/src/combos/failover.ts +32 -5
- package/src/combos/request.ts +11 -3
- package/src/combos/reset-window.ts +10 -5
- package/src/combos/resolve.ts +36 -6
- package/src/config/diagnostics.ts +2 -1
- package/src/config/load-degrade.ts +2 -0
- package/src/config/paths.ts +7 -1
- package/src/config/process-state.ts +10 -1
- package/src/config/schema/compaction-recovery.ts +15 -0
- package/src/config/schema/config-schema.ts +2 -0
- package/src/config/schema/leaf-validators.ts +9 -3
- package/src/generated/compatibility-version.json +360 -204
- package/src/images/loop.ts +47 -32
- package/src/integrations/catalog-refresh.ts +4 -0
- package/src/integrations/omp-yaml-source.ts +1 -0
- package/src/lib/claude-request-projection.ts +101 -0
- package/src/lib/local-account-switch-capability.ts +48 -0
- package/src/lib/local-upstream.ts +146 -0
- package/src/lib/package-tree-integrity.ts +25 -3
- package/src/lib/package-tree-retarget.ts +130 -0
- package/src/lib/process-control.ts +4 -1
- package/src/lib/system-restart-contract.ts +106 -0
- package/src/lib/upstream-retry.ts +57 -1
- package/src/link/ports.ts +9 -0
- package/src/link/ssh-argv.ts +16 -0
- package/src/link/ssh-runner.ts +70 -2
- package/src/link/tunnel-state.ts +58 -9
- package/src/oauth/account-quota-rank.ts +29 -18
- package/src/oauth/generic-account-failover.ts +179 -22
- package/src/oauth/index.ts +17 -4
- package/src/oauth/kiro-account-load.ts +107 -0
- package/src/oauth/kiro-device-login.ts +312 -0
- package/src/oauth/kiro-terminal-failover.ts +28 -0
- package/src/oauth/login-flow-state.ts +7 -1
- package/src/oauth/pool-settings-capability.ts +23 -14
- package/src/oauth/store.ts +56 -3
- package/src/oauth/types.ts +4 -0
- package/src/plugins/loader.ts +350 -0
- package/src/plugins/upstream-hooks.ts +119 -0
- package/src/protocols/encoders/adapter-events.ts +9 -2
- package/src/providers/account-quota-disk.ts +42 -4
- package/src/providers/codebuddy-models.ts +6 -2
- package/src/providers/command-code-efforts.ts +33 -10
- package/src/providers/kiro-account-state-disk.ts +44 -0
- package/src/providers/kiro-model-catalog.ts +162 -0
- package/src/providers/kiro-models.ts +5 -4
- package/src/providers/kiro-quota-metrics.ts +35 -0
- package/src/providers/kiro-usage.ts +118 -14
- package/src/providers/quota/account-cache.ts +43 -7
- package/src/providers/quota/antigravity.ts +8 -3
- package/src/providers/quota/kiro-account-probe.ts +12 -0
- package/src/providers/quota/vendor-probes-key.ts +31 -26
- package/src/providers/quota/vendor-probes-oauth.ts +16 -7
- package/src/providers/quota-types.ts +14 -0
- package/src/providers/quota.ts +21 -21
- package/src/providers/registry/entries-extended.ts +5 -3
- package/src/providers/request-pacing.ts +410 -12
- package/src/remote-control/workspace-codex-runtime.ts +4 -3
- package/src/responses/citation-markers.ts +132 -65
- package/src/responses/hosted-tool-policy.ts +14 -3
- package/src/responses/parser-content.ts +3 -2
- package/src/responses/parser.ts +4 -1
- package/src/responses/schema.ts +4 -0
- package/src/responses/visualization-directives.ts +182 -0
- package/src/server/chat-native-sse.ts +24 -12
- package/src/server/chat-native.ts +137 -68
- package/src/server/claude-messages.ts +147 -12
- package/src/server/index/link-listener.ts +7 -0
- package/src/server/index/optional-listeners.ts +4 -1
- package/src/server/index/package-tree-guard.ts +35 -16
- package/src/server/index/serve-options.ts +4 -2
- package/src/server/index/startup-warnings.ts +6 -0
- package/src/server/index.ts +7 -7
- package/src/server/inference/client-encoder-delivery.ts +3 -0
- package/src/server/inference/context.ts +30 -2
- package/src/server/local-account-switch-auth.ts +79 -0
- package/src/server/management/agent-settings-routes.ts +11 -3
- package/src/server/management/config-routes.ts +28 -11
- package/src/server/management/context.ts +2 -0
- package/src/server/management/link-routes.ts +123 -39
- package/src/server/management/logs-usage-routes.ts +9 -42
- package/src/server/management/model-rows.ts +1 -0
- package/src/server/management/oauth-account-routes.ts +67 -12
- package/src/server/management/route-registry.ts +5 -5
- package/src/server/management/sibling-guard.ts +60 -0
- package/src/server/management/storage-log-guard-routes.ts +72 -6
- package/src/server/management/system-restart.ts +92 -135
- package/src/server/management/system-routes.ts +1 -1
- package/src/server/management-api.ts +31 -3
- package/src/server/management-auth.ts +3 -0
- package/src/server/port-reclaim.ts +111 -1
- package/src/server/proxy-liveness.ts +16 -0
- package/src/server/relay.ts +16 -15
- package/src/server/request-log-filter.ts +28 -27
- package/src/server/request-log.ts +7 -3
- package/src/server/request-metrics.ts +28 -0
- package/src/server/responses/adapter-continuation.ts +151 -21
- package/src/server/responses/adapter-delivery.ts +21 -7
- package/src/server/responses/adapter-dispatch.ts +239 -51
- package/src/server/responses/codex-ws-pool.ts +5 -4
- package/src/server/responses/codex-ws-request.ts +4 -1
- package/src/server/responses/compact.ts +8 -4
- package/src/server/responses/compaction-recovery-policy.ts +116 -0
- package/src/server/responses/compaction-recovery.ts +330 -0
- package/src/server/responses/core-combo-failure.ts +1 -0
- package/src/server/responses/core-combo.ts +5 -1
- package/src/server/responses/core-lifetime.ts +22 -0
- package/src/server/responses/core-options.ts +16 -1
- package/src/server/responses/core.ts +14 -4
- package/src/server/responses/empty-completion-guard.ts +2 -0
- package/src/server/responses/fetch-helpers.ts +82 -31
- package/src/server/responses/passthrough-delivery.ts +3 -2
- package/src/server/responses/passthrough-dispatch.ts +12 -2
- package/src/server/responses/passthrough-execution.ts +2 -2
- package/src/server/responses/request-send-budget.ts +21 -5
- package/src/server/responses/request-transport.ts +116 -18
- package/src/server/responses/reset-replay.ts +32 -0
- package/src/server/responses/run-turn-execution.ts +136 -19
- package/src/server/responses/sidecar-execution.ts +41 -8
- package/src/server/responses/terminal-guard.ts +2 -0
- package/src/server/responses/ws-upstream.ts +44 -4
- package/src/server/restart-replacement.ts +382 -0
- package/src/server/startup-health-cache.ts +23 -4
- package/src/server/stop-teardown.ts +35 -1
- package/src/server/system-env.ts +3 -0
- package/src/service/windows-taskxml.ts +11 -7
- package/src/service/windows-wrapper-exit.ts +8 -0
- package/src/stall-timeout.ts +35 -10
- package/src/storage/policy-job.ts +4 -0
- package/src/storage/scanner.ts +177 -29
- package/src/storage/storage-mutation-coordinator.ts +17 -0
- package/src/tray/windows-tray.ps1 +50 -23
- package/src/types/config.ts +4 -0
- package/src/types/provider.ts +5 -1
- package/src/types/request.ts +13 -2
- package/src/update/index.ts +3 -1
- package/src/update/job.ts +6 -2
- package/src/update/mise-launcher-target.ts +100 -0
- package/src/update/worker-launch.ts +46 -0
- package/src/usage/log.ts +2 -0
- package/src/web-search/loop.ts +5 -2
- package/gui/dist/assets/App-BJsT8Icc.css +0 -1
- package/gui/dist/assets/App-D3pNGiN4.js +0 -51
- package/gui/dist/assets/Tray-yb05wCjb.js +0 -1
- package/gui/dist/assets/index-CouvvtMV.js +0 -86
- package/gui/dist/assets/tray-data-CYGdjCJ7.js +0 -1
|
@@ -1,19 +1,29 @@
|
|
|
1
|
-
import { chmodSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
1
|
+
import { chmodSync, mkdirSync, readFileSync, statSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { execFile } from "node:child_process";
|
|
3
|
+
import { readFile as readFileAsync } from "node:fs/promises";
|
|
2
4
|
import { dirname, join } from "node:path";
|
|
5
|
+
import { promisify } from "node:util";
|
|
3
6
|
import { atomicWriteFile, isMissingPathError } from "../config/atomic-write";
|
|
7
|
+
import { getConfigPath } from "../config/paths";
|
|
8
|
+
import { readBoundedResponseBytes } from "../lib/bounded-body";
|
|
4
9
|
import { linkDir, linkKnownHostsPath } from "../link/paths";
|
|
5
10
|
import { buildTunnelArgv } from "../link/ssh-argv";
|
|
6
11
|
import { createSshRunner, type SshChild, type SshRunner } from "../link/ssh-runner";
|
|
7
12
|
import {
|
|
13
|
+
CLIENT_TUNNEL_RETRY_POLICY,
|
|
8
14
|
classifySshStderr,
|
|
9
15
|
dueForSpawn,
|
|
16
|
+
failedTunnel,
|
|
10
17
|
IDLE,
|
|
11
18
|
reduceTunnel,
|
|
19
|
+
type StderrClass,
|
|
12
20
|
type TunnelState,
|
|
13
21
|
} from "../link/tunnel-state";
|
|
14
22
|
import { isLinkPort } from "../link/ports";
|
|
23
|
+
import { ownsIpv4LoopbackListener } from "../server/port-reclaim";
|
|
15
24
|
import { isLinkConnection, readClientConnectionState } from "./state";
|
|
16
25
|
import { clientLinkStatePath, readClientLinkState, type ClientLinkState } from "./link-state";
|
|
26
|
+
import type { LinkTunnelGate } from "./link-relay";
|
|
17
27
|
|
|
18
28
|
/**
|
|
19
29
|
* The client-owned `ssh -N -L 127.0.0.1:<tunnelPort>:127.0.0.1:<peerListenerPort> <alias>`
|
|
@@ -29,6 +39,7 @@ export interface ClientLinkTunnelSpec {
|
|
|
29
39
|
|
|
30
40
|
export interface ClientLinkTunnelHandle {
|
|
31
41
|
readonly pid: number;
|
|
42
|
+
readonly startTime: string | null;
|
|
32
43
|
readonly exited: Promise<number>;
|
|
33
44
|
/** TERM, wait up to 5 s, then KILL; removes the pidfile this handle wrote. Idempotent. */
|
|
34
45
|
stop(): Promise<void>;
|
|
@@ -45,21 +56,39 @@ export interface ClientLinkTunnelDeps {
|
|
|
45
56
|
export type OrphanTunnelResult =
|
|
46
57
|
| { tunnel: "reaped" }
|
|
47
58
|
| { tunnel: "absent" }
|
|
48
|
-
| { tunnel: "owned" }
|
|
59
|
+
| { tunnel: "owned"; pid?: number }
|
|
49
60
|
| { tunnel: "unresolved"; pid: number };
|
|
50
61
|
|
|
62
|
+
/** A macOS process as `ps -o ppid= -o args=` shows it: the parent pid and the space-joined argv. */
|
|
63
|
+
export interface DarwinProcessInfo {
|
|
64
|
+
ppid: number;
|
|
65
|
+
startTime: string;
|
|
66
|
+
args: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
51
69
|
export interface OrphanReapDeps {
|
|
52
70
|
configDir?: string;
|
|
53
71
|
platform?: NodeJS.Platform;
|
|
54
72
|
readProcessArgv?: (pid: number) => readonly string[] | null;
|
|
73
|
+
/** Stable process start identity; unavailable identity means the orphan is never signalled. */
|
|
74
|
+
readProcessStartTime?: (pid: number, platform: NodeJS.Platform) => string | null;
|
|
75
|
+
/** macOS: the parent pid and argv of `pid`, or null when they cannot be read. */
|
|
76
|
+
readProcessInfo?: (pid: number) => DarwinProcessInfo | null;
|
|
55
77
|
isAlive?: (pid: number) => boolean;
|
|
56
78
|
signal?: (pid: number, signal: NodeJS.Signals) => void;
|
|
57
79
|
sleep?: (ms: number) => Promise<void>;
|
|
58
80
|
}
|
|
59
81
|
|
|
82
|
+
/**
|
|
83
|
+
* What the periodic keyed probe last saw, for display only: it never changes a connected tunnel.
|
|
84
|
+
* `home_not_ready` is a Home that admitted the key but reports its own startup readiness as pending
|
|
85
|
+
* or failed; its link still works.
|
|
86
|
+
*/
|
|
87
|
+
export type ClientTunnelProbeReason = "unauthorized" | "home_unreachable" | "home_not_ready";
|
|
88
|
+
|
|
60
89
|
export type ClientLinkSupervisorStatus =
|
|
61
90
|
| { kind: "stopped" }
|
|
62
|
-
| { kind: "tunnel"; linkId: string; state: TunnelState; pid: number | null }
|
|
91
|
+
| { kind: "tunnel"; linkId: string; state: TunnelState; pid: number | null; probe?: ClientTunnelProbeReason }
|
|
63
92
|
| { kind: "failed"; reason: "sidecar_invalid" };
|
|
64
93
|
|
|
65
94
|
export interface ClientLinkTunnelStatusProjection {
|
|
@@ -69,7 +98,8 @@ export interface ClientLinkTunnelStatusProjection {
|
|
|
69
98
|
reason: "sidecar_invalid";
|
|
70
99
|
}
|
|
71
100
|
|
|
72
|
-
|
|
101
|
+
/** The supervisor is also the relay's tunnel gate: requests wait on it while it reconnects. */
|
|
102
|
+
export interface ClientLinkSupervisor extends LinkTunnelGate {
|
|
73
103
|
start(): void;
|
|
74
104
|
/** Stops the tunnel (TERM, up to 5 s, KILL). The runtime calls this before stopping its listener. */
|
|
75
105
|
stop(): Promise<void>;
|
|
@@ -89,12 +119,23 @@ export function clientLinkTunnelStatus(
|
|
|
89
119
|
}
|
|
90
120
|
}
|
|
91
121
|
|
|
122
|
+
/** `connectedLinkId` when the connection state could not be read (a write in flight, a bad file). */
|
|
123
|
+
export const CONNECTION_UNREADABLE = "unreadable";
|
|
124
|
+
|
|
92
125
|
export interface ClientLinkSupervisorDeps extends ClientLinkTunnelDeps, OrphanReapDeps {
|
|
93
126
|
readSidecar?: () => ClientLinkState | null;
|
|
94
|
-
/**
|
|
95
|
-
|
|
127
|
+
/**
|
|
128
|
+
* Current link id of a connected link-transport client, null when that no longer holds, or
|
|
129
|
+
* `CONNECTION_UNREADABLE` when the connection state could not be read.
|
|
130
|
+
*/
|
|
131
|
+
connectedLinkId?: () => string | null | typeof CONNECTION_UNREADABLE;
|
|
96
132
|
/** Called once after the tunnel stopped because the link ended (the runtime recycles here). */
|
|
97
133
|
onLinkEnded?: () => void;
|
|
134
|
+
/** The link key for the keyed readiness probe. The runtime passes its cached key source. */
|
|
135
|
+
linkKey?: () => string | null;
|
|
136
|
+
/** Async local IPv4 LISTEN ownership; an unknown or foreign owner never receives the link key. */
|
|
137
|
+
ownsLoopbackListener?: (port: number, pid: number) => Promise<boolean>;
|
|
138
|
+
fetchImpl?: typeof fetch;
|
|
98
139
|
now?: () => number;
|
|
99
140
|
random?: () => number;
|
|
100
141
|
warn?: (message: string) => void;
|
|
@@ -115,11 +156,25 @@ export interface ClientTunnelPidfile {
|
|
|
115
156
|
pid: number;
|
|
116
157
|
argv: string[];
|
|
117
158
|
ownerPid: number;
|
|
159
|
+
/** Captured at spawn; legacy pidfiles without it cannot authorize a signal. */
|
|
160
|
+
startTime: string | null;
|
|
118
161
|
}
|
|
119
162
|
|
|
120
163
|
const STOP_TIMEOUT_MS = 5_000;
|
|
164
|
+
const REAP_POLL_MS = 100;
|
|
121
165
|
const TIMER_MS = 1_000;
|
|
122
|
-
|
|
166
|
+
/** Consecutive unreadable reads before the supervisor acts on them. */
|
|
167
|
+
export const INVALID_READ_TICKS = 3;
|
|
168
|
+
/** How often a connected tunnel is probed. The result is display-only. */
|
|
169
|
+
export const CONNECTED_PROBE_MS = 30_000;
|
|
170
|
+
const PROBE_TIMEOUT_MS = 5_000;
|
|
171
|
+
const PROBE_BACKOFF_MAX_MS = 5_000;
|
|
172
|
+
/** A Home `/readyz` body is a few hundred bytes; a larger one is not the Home's. */
|
|
173
|
+
const PROBE_BODY_MAX_BYTES = 4_096;
|
|
174
|
+
/** Requests that may wait on a reconnecting tunnel at once; more are answered 503 at once. */
|
|
175
|
+
export const CLIENT_LINK_MAX_HOLDS = 64;
|
|
176
|
+
const PROCESS_IDENTITY_TIMEOUT_MS = 2_000;
|
|
177
|
+
const execFileAsync = promisify(execFile);
|
|
123
178
|
|
|
124
179
|
function sameArgv(left: readonly string[], right: readonly string[]): boolean {
|
|
125
180
|
return left.length === right.length && left.every((value, index) => value === right[index]);
|
|
@@ -131,13 +186,16 @@ function parsePidfile(value: unknown): ClientTunnelPidfile | null {
|
|
|
131
186
|
if (raw.version !== 1 || typeof raw.linkId !== "string" || typeof raw.pid !== "number"
|
|
132
187
|
|| !Number.isSafeInteger(raw.pid) || raw.pid < 1 || !Array.isArray(raw.argv)
|
|
133
188
|
|| raw.argv.length === 0 || raw.argv.some(item => typeof item !== "string")
|
|
134
|
-
|| typeof raw.ownerPid !== "number" || !Number.isSafeInteger(raw.ownerPid) || raw.ownerPid < 1
|
|
189
|
+
|| typeof raw.ownerPid !== "number" || !Number.isSafeInteger(raw.ownerPid) || raw.ownerPid < 1
|
|
190
|
+
|| (raw.startTime !== undefined && raw.startTime !== null
|
|
191
|
+
&& (typeof raw.startTime !== "string" || raw.startTime.length === 0 || raw.startTime.length > 128))) return null;
|
|
135
192
|
return {
|
|
136
193
|
version: 1,
|
|
137
194
|
linkId: raw.linkId,
|
|
138
195
|
pid: raw.pid,
|
|
139
196
|
argv: raw.argv as string[],
|
|
140
197
|
ownerPid: raw.ownerPid,
|
|
198
|
+
startTime: typeof raw.startTime === "string" ? raw.startTime : null,
|
|
141
199
|
};
|
|
142
200
|
}
|
|
143
201
|
|
|
@@ -157,9 +215,9 @@ function writePidfile(path: string, value: ClientTunnelPidfile): void {
|
|
|
157
215
|
if (process.platform !== "win32") chmodSync(path, 0o600);
|
|
158
216
|
}
|
|
159
217
|
|
|
160
|
-
function removePidfileIfPid(path: string, pid: number): void {
|
|
218
|
+
function removePidfileIfPid(path: string, pid: number, startTime: string | null): void {
|
|
161
219
|
const current = readPidfile(path);
|
|
162
|
-
if (current?.pid !== pid) return;
|
|
220
|
+
if (current?.pid !== pid || current.startTime !== startTime) return;
|
|
163
221
|
try {
|
|
164
222
|
unlinkSync(path);
|
|
165
223
|
} catch (error) {
|
|
@@ -187,15 +245,101 @@ function defaultIsAlive(pid: number): boolean {
|
|
|
187
245
|
|
|
188
246
|
function linuxProcessArgv(pid: number): readonly string[] | null {
|
|
189
247
|
try {
|
|
190
|
-
|
|
191
|
-
if (values.at(-1) === "") values.pop();
|
|
192
|
-
return values.length > 0 ? values : null;
|
|
248
|
+
return parseLinuxArgv(readFileSync(`/proc/${pid}/cmdline`).toString());
|
|
193
249
|
} catch (error) {
|
|
194
250
|
if (isMissingPathError(error)) return null;
|
|
195
251
|
return null;
|
|
196
252
|
}
|
|
197
253
|
}
|
|
198
254
|
|
|
255
|
+
function parseLinuxArgv(content: string): readonly string[] | null {
|
|
256
|
+
const values = content.split("\0");
|
|
257
|
+
if (values.at(-1) === "") values.pop();
|
|
258
|
+
return values.length > 0 ? values : null;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function parseLinuxStartTime(stat: string): string | null {
|
|
262
|
+
const afterCommand = stat.lastIndexOf(") ");
|
|
263
|
+
if (afterCommand < 0) return null;
|
|
264
|
+
const startTime = stat.slice(afterCommand + 2).trim().split(/\s+/)[19];
|
|
265
|
+
return startTime && /^\d+$/.test(startTime) ? startTime : null;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Field 22 of /proc/<pid>/stat: kernel start ticks survive argv and parent changes. */
|
|
269
|
+
function linuxProcessStartTime(pid: number): string | null {
|
|
270
|
+
try {
|
|
271
|
+
return parseLinuxStartTime(readFileSync(`/proc/${pid}/stat`, "utf8"));
|
|
272
|
+
} catch {
|
|
273
|
+
return null;
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function parseDarwinProcessInfo(output: string): DarwinProcessInfo | null {
|
|
278
|
+
const match = /^\s*(\d+)\s+(\S+\s+\S+\s+\d{1,2}\s+\d{2}:\d{2}:\d{2}\s+\d{4})\s+(.+)$/.exec(output.replace(/\r?\n$/, ""));
|
|
279
|
+
return match ? { ppid: Number(match[1]), startTime: match[2]!, args: match[3]! } : null;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** One `ps` snapshot carries parent, second-resolution start time and the full argv. */
|
|
283
|
+
function darwinProcessInfo(pid: number): DarwinProcessInfo | null {
|
|
284
|
+
try {
|
|
285
|
+
const result = Bun.spawnSync(["/bin/ps", "-ww", "-o", "ppid=", "-o", "lstart=", "-o", "args=", "-p", String(pid)], {
|
|
286
|
+
stdin: "ignore",
|
|
287
|
+
stdout: "pipe",
|
|
288
|
+
stderr: "ignore",
|
|
289
|
+
});
|
|
290
|
+
if (result.exitCode !== 0) return null;
|
|
291
|
+
return parseDarwinProcessInfo(result.stdout.toString());
|
|
292
|
+
} catch {
|
|
293
|
+
return null;
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
type AdoptedIdentityVerdict = "match" | "mismatch" | "unknown";
|
|
298
|
+
|
|
299
|
+
/** An unreadable identity denies this admission; only readable disagreement releases adoption. */
|
|
300
|
+
async function inspectAdoptedIdentity(record: ClientTunnelPidfile, deps: OrphanReapDeps): Promise<AdoptedIdentityVerdict> {
|
|
301
|
+
if (!record.startTime) return "unknown";
|
|
302
|
+
const platform = deps.platform ?? process.platform;
|
|
303
|
+
const check = async (): Promise<AdoptedIdentityVerdict> => {
|
|
304
|
+
if (platform === "linux") {
|
|
305
|
+
const [argv, startTime] = await Promise.all([
|
|
306
|
+
deps.readProcessArgv
|
|
307
|
+
? Promise.resolve().then(() => deps.readProcessArgv!(record.pid))
|
|
308
|
+
: readFileAsync(`/proc/${record.pid}/cmdline`, "utf8").then(parseLinuxArgv),
|
|
309
|
+
deps.readProcessStartTime
|
|
310
|
+
? Promise.resolve().then(() => deps.readProcessStartTime!(record.pid, platform))
|
|
311
|
+
: readFileAsync(`/proc/${record.pid}/stat`, "utf8").then(parseLinuxStartTime),
|
|
312
|
+
]);
|
|
313
|
+
if (argv === null || startTime === null) return "unknown";
|
|
314
|
+
return sameArgv(argv, record.argv) && startTime === record.startTime ? "match" : "mismatch";
|
|
315
|
+
}
|
|
316
|
+
if (platform === "darwin") {
|
|
317
|
+
const info = deps.readProcessInfo
|
|
318
|
+
? await Promise.resolve().then(() => deps.readProcessInfo!(record.pid))
|
|
319
|
+
: await execFileAsync("/bin/ps", ["-ww", "-o", "ppid=", "-o", "lstart=", "-o", "args=", "-p", String(record.pid)], {
|
|
320
|
+
encoding: "utf8", timeout: PROCESS_IDENTITY_TIMEOUT_MS, maxBuffer: 64 * 1024, windowsHide: true,
|
|
321
|
+
}).then(result => parseDarwinProcessInfo(result.stdout));
|
|
322
|
+
if (info === null) return "unknown";
|
|
323
|
+
return info.args === record.argv.join(" ") && info.startTime === record.startTime ? "match" : "mismatch";
|
|
324
|
+
}
|
|
325
|
+
return "unknown";
|
|
326
|
+
};
|
|
327
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
328
|
+
const deadline = new Promise<"unknown">(resolve => {
|
|
329
|
+
timer = setTimeout(() => resolve("unknown"), PROCESS_IDENTITY_TIMEOUT_MS);
|
|
330
|
+
timer.unref?.();
|
|
331
|
+
});
|
|
332
|
+
try { return await Promise.race([check().catch(() => "unknown" as const), deadline]); }
|
|
333
|
+
finally { if (timer) clearTimeout(timer); }
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function processStartTime(pid: number, platform: NodeJS.Platform, deps: OrphanReapDeps): string | null {
|
|
337
|
+
if (deps.readProcessStartTime) return deps.readProcessStartTime(pid, platform);
|
|
338
|
+
if (platform === "linux") return linuxProcessStartTime(pid);
|
|
339
|
+
if (platform === "darwin") return (deps.readProcessInfo ?? darwinProcessInfo)(pid)?.startTime ?? null;
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
|
|
199
343
|
function timerDeps(deps: ClientLinkTunnelDeps): Required<Pick<ClientLinkTunnelDeps, "setTimer" | "clearTimer">> {
|
|
200
344
|
return {
|
|
201
345
|
setTimer: deps.setTimer ?? ((callback, ms) => setTimeout(callback, ms)),
|
|
@@ -240,8 +384,9 @@ export function spawnClientLinkTunnel(spec: ClientLinkTunnelSpec, deps: ClientLi
|
|
|
240
384
|
});
|
|
241
385
|
const child = runner.spawnTunnel(argv);
|
|
242
386
|
const pidfile = clientTunnelPidfilePath(deps.configDir);
|
|
387
|
+
const startTime = processStartTime(child.pid, process.platform, deps);
|
|
243
388
|
try {
|
|
244
|
-
writePidfile(pidfile, { version: 1, linkId: spec.linkId, pid: child.pid, argv: [...argv], ownerPid: process.pid });
|
|
389
|
+
writePidfile(pidfile, { version: 1, linkId: spec.linkId, pid: child.pid, argv: [...argv], ownerPid: process.pid, startTime });
|
|
245
390
|
} catch (error) {
|
|
246
391
|
try { child.kill("SIGTERM"); } catch (killError) { if ((killError as NodeJS.ErrnoException).code !== "ESRCH") throw killError; }
|
|
247
392
|
throw error;
|
|
@@ -249,15 +394,16 @@ export function spawnClientLinkTunnel(spec: ClientLinkTunnelSpec, deps: ClientLi
|
|
|
249
394
|
|
|
250
395
|
let stopPromise: Promise<void> | undefined;
|
|
251
396
|
const handleExit = (): void => {
|
|
252
|
-
removePidfileIfPid(pidfile, child.pid);
|
|
397
|
+
removePidfileIfPid(pidfile, child.pid, startTime);
|
|
253
398
|
};
|
|
254
399
|
const handle = {
|
|
255
400
|
pid: child.pid,
|
|
401
|
+
startTime,
|
|
256
402
|
exited: child.exited,
|
|
257
403
|
stderr: child.stderr,
|
|
258
404
|
stop(): Promise<void> {
|
|
259
405
|
if (stopPromise) return stopPromise;
|
|
260
|
-
stopPromise = stopChild(child, deps).finally(() => removePidfileIfPid(pidfile, child.pid));
|
|
406
|
+
stopPromise = stopChild(child, deps).finally(() => removePidfileIfPid(pidfile, child.pid, startTime));
|
|
261
407
|
return stopPromise;
|
|
262
408
|
},
|
|
263
409
|
} satisfies ClientLinkTunnelHandle & { stderr?: Promise<string> };
|
|
@@ -265,44 +411,175 @@ export function spawnClientLinkTunnel(spec: ClientLinkTunnelSpec, deps: ClientLi
|
|
|
265
411
|
return handle;
|
|
266
412
|
}
|
|
267
413
|
|
|
414
|
+
type TunnelIdentity =
|
|
415
|
+
| { kind: "gone" }
|
|
416
|
+
| { kind: "other" }
|
|
417
|
+
| { kind: "ours"; orphaned: boolean; startTime: string }
|
|
418
|
+
| { kind: "unknown" };
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Whether the pidfile's process is still our tunnel. Linux compares `/proc/<pid>/cmdline` with
|
|
422
|
+
* the recorded argv. macOS compares `ps` args with the argv joined by spaces and reports the
|
|
423
|
+
* process orphaned only when launchd (pid 1) is its parent. Elsewhere a live process is unknown.
|
|
424
|
+
*/
|
|
425
|
+
function tunnelIdentity(
|
|
426
|
+
pidfile: ClientTunnelPidfile,
|
|
427
|
+
platform: NodeJS.Platform,
|
|
428
|
+
deps: OrphanReapDeps,
|
|
429
|
+
isAlive: (pid: number) => boolean,
|
|
430
|
+
): TunnelIdentity {
|
|
431
|
+
if (platform === "linux") {
|
|
432
|
+
const actualArgv = (deps.readProcessArgv ?? linuxProcessArgv)(pidfile.pid);
|
|
433
|
+
if (!actualArgv) return { kind: "gone" };
|
|
434
|
+
if (!sameArgv(actualArgv, pidfile.argv)) return { kind: "other" };
|
|
435
|
+
const startTime = processStartTime(pidfile.pid, platform, deps);
|
|
436
|
+
if (!pidfile.startTime || !startTime) return { kind: "unknown" };
|
|
437
|
+
return startTime === pidfile.startTime ? { kind: "ours", orphaned: true, startTime } : { kind: "other" };
|
|
438
|
+
}
|
|
439
|
+
if (!isAlive(pidfile.pid)) return { kind: "gone" };
|
|
440
|
+
if (platform !== "darwin") return { kind: "unknown" };
|
|
441
|
+
const info = (deps.readProcessInfo ?? darwinProcessInfo)(pidfile.pid);
|
|
442
|
+
if (!info) return { kind: "unknown" };
|
|
443
|
+
if (info.args !== pidfile.argv.join(" ")) return { kind: "other" };
|
|
444
|
+
if (!pidfile.startTime || !info.startTime) return { kind: "unknown" };
|
|
445
|
+
return info.startTime === pidfile.startTime
|
|
446
|
+
? { kind: "ours", orphaned: info.ppid === 1, startTime: info.startTime }
|
|
447
|
+
: { kind: "other" };
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Settle a leftover tunnel pidfile before a new tunnel starts.
|
|
452
|
+
*
|
|
453
|
+
* - A pidfile whose process is gone, or is provably another program (a reused pid, as after a
|
|
454
|
+
* reboot), is stale: it is removed and the result is `absent`, so a new tunnel starts.
|
|
455
|
+
* - While the owner lives, a tunnel that is still ours (or cannot be told apart) is `owned`.
|
|
456
|
+
* - After the owner exits, a proven orphan is reaped: Linux on an exact `/proc` argv match, macOS
|
|
457
|
+
* on an exact `ps` argv match with launchd as the parent. TERM, up to five seconds, KILL.
|
|
458
|
+
* - Anything else is `unresolved`: never signalled; the caller watches it.
|
|
459
|
+
*/
|
|
268
460
|
export async function reapOrphanTunnel(deps: OrphanReapDeps = {}): Promise<OrphanTunnelResult> {
|
|
269
461
|
const path = clientTunnelPidfilePath(deps.configDir);
|
|
270
462
|
const pidfile = readPidfile(path);
|
|
271
463
|
if (!pidfile) return { tunnel: "absent" };
|
|
272
464
|
const isAlive = deps.isAlive ?? defaultIsAlive;
|
|
273
|
-
if (isAlive(pidfile.ownerPid)) return { tunnel: "owned" };
|
|
274
465
|
const platform = deps.platform ?? process.platform;
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
if (!actualArgv || !sameArgv(actualArgv, pidfile.argv)) {
|
|
279
|
-
try { unlinkSync(path); } catch (error) { if (!isMissingPathError(error)) throw error; }
|
|
466
|
+
const identity = tunnelIdentity(pidfile, platform, deps, isAlive);
|
|
467
|
+
if (identity.kind === "gone" || identity.kind === "other") {
|
|
468
|
+
removePidfileIfPid(path, pidfile.pid, pidfile.startTime);
|
|
280
469
|
return { tunnel: "absent" };
|
|
281
470
|
}
|
|
471
|
+
if (isAlive(pidfile.ownerPid)) return { tunnel: "owned", pid: pidfile.pid };
|
|
472
|
+
if (identity.kind !== "ours" || !identity.orphaned) return { tunnel: "unresolved", pid: pidfile.pid };
|
|
473
|
+
// The pid may have been reused since the first read. Recheck the saved start identity and
|
|
474
|
+
// orphan proof immediately before each signal, including the escalation after the wait.
|
|
475
|
+
const stillOurOrphan = (): boolean => {
|
|
476
|
+
const current = tunnelIdentity(pidfile, platform, deps, isAlive);
|
|
477
|
+
return current.kind === "ours" && current.orphaned && current.startTime === identity.startTime;
|
|
478
|
+
};
|
|
479
|
+
if (!stillOurOrphan()) return { tunnel: "unresolved", pid: pidfile.pid };
|
|
282
480
|
const signal = deps.signal ?? defaultSignal;
|
|
283
481
|
const sleep = deps.sleep ?? ((ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms)));
|
|
284
482
|
try { signal(pidfile.pid, "SIGTERM"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
|
|
285
|
-
await sleep(
|
|
483
|
+
for (let waited = 0; waited < STOP_TIMEOUT_MS && isAlive(pidfile.pid); waited += REAP_POLL_MS) await sleep(REAP_POLL_MS);
|
|
286
484
|
if (isAlive(pidfile.pid)) {
|
|
485
|
+
if (!stillOurOrphan()) return { tunnel: "unresolved", pid: pidfile.pid };
|
|
287
486
|
try { signal(pidfile.pid, "SIGKILL"); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
|
|
288
487
|
}
|
|
289
|
-
|
|
488
|
+
removePidfileIfPid(path, pidfile.pid, pidfile.startTime);
|
|
290
489
|
return { tunnel: "reaped" };
|
|
291
490
|
}
|
|
292
491
|
|
|
293
|
-
function defaultConnectedLinkId(): string | null {
|
|
492
|
+
function defaultConnectedLinkId(): string | null | typeof CONNECTION_UNREADABLE {
|
|
294
493
|
const state = readClientConnectionState();
|
|
494
|
+
if (state.kind === "invalid" || state.kind === "mismatched") return CONNECTION_UNREADABLE;
|
|
295
495
|
if (state.kind !== "connected" || !isLinkConnection(state.value)) return null;
|
|
296
496
|
return state.value.link?.linkId ?? null;
|
|
297
497
|
}
|
|
298
498
|
|
|
499
|
+
function fileSignature(path: string): string | null {
|
|
500
|
+
try {
|
|
501
|
+
const stat = statSync(path);
|
|
502
|
+
return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeMs}:${stat.ctimeMs}`;
|
|
503
|
+
} catch (error) {
|
|
504
|
+
return isMissingPathError(error) ? "missing" : null;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* `read` again only when the file at `path` changed, so a steady link costs one stat per check
|
|
510
|
+
* instead of a parse. A read that throws, a value `keep` refuses, or a failed stat is never cached.
|
|
511
|
+
*/
|
|
512
|
+
export function readWhenFileChanges<T>(path: () => string, read: () => T, keep: (value: T) => boolean = () => true): () => T {
|
|
513
|
+
let cached: { signature: string; value: T } | null = null;
|
|
514
|
+
return () => {
|
|
515
|
+
const signature = fileSignature(path());
|
|
516
|
+
if (signature !== null && cached?.signature === signature) return cached.value;
|
|
517
|
+
cached = null;
|
|
518
|
+
const value = read();
|
|
519
|
+
if (signature !== null && keep(value)) cached = { signature, value };
|
|
520
|
+
return value;
|
|
521
|
+
};
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** `ready` and `home_not_ready` both prove the link works; the rest do not. */
|
|
525
|
+
type ProbeResult = "ready" | ClientTunnelProbeReason;
|
|
526
|
+
|
|
527
|
+
/** Whether a 503 `/readyz` body is the Home's own readiness answer (`service: "opencodex"`). */
|
|
528
|
+
async function isOpencodexReadiness(response: Response, signal: AbortSignal): Promise<boolean> {
|
|
529
|
+
try {
|
|
530
|
+
const { bytes, oversized } = await readBoundedResponseBytes(response, { maxBytes: PROBE_BODY_MAX_BYTES, signal });
|
|
531
|
+
if (oversized) return false;
|
|
532
|
+
const body = JSON.parse(new TextDecoder().decode(bytes)) as unknown;
|
|
533
|
+
return typeof body === "object" && body !== null && (body as { service?: unknown }).service === "opencodex";
|
|
534
|
+
} catch {
|
|
535
|
+
return false;
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* `GET /readyz` through the tunnel with the link key; the key is sent as a header only. The Home's
|
|
541
|
+
* link listener answers 401 before it reaches `/readyz`, so both a 200 and a 503 carrying the
|
|
542
|
+
* Home's readiness body prove that the forward reaches that listener and that the key is admitted.
|
|
543
|
+
* The caller first proves the local LISTEN socket belongs to its SSH process; an arbitrary 200
|
|
544
|
+
* from a competing process never earns a keyed request.
|
|
545
|
+
* The 503 only means the Home's own startup readiness is pending or failed, which does not stop
|
|
546
|
+
* relayed requests; it is reported as `home_not_ready`, for display.
|
|
547
|
+
*/
|
|
548
|
+
async function probeTunnel(fetchImpl: typeof fetch, tunnelPort: number, key: string, stop: AbortSignal): Promise<ProbeResult> {
|
|
549
|
+
const signal = AbortSignal.any([stop, AbortSignal.timeout(PROBE_TIMEOUT_MS)]);
|
|
550
|
+
try {
|
|
551
|
+
const response = await fetchImpl(`http://127.0.0.1:${tunnelPort}/readyz`, {
|
|
552
|
+
headers: { "x-opencodex-api-key": key },
|
|
553
|
+
cache: "no-store",
|
|
554
|
+
redirect: "manual",
|
|
555
|
+
signal,
|
|
556
|
+
});
|
|
557
|
+
if (response.status === 503) return await isOpencodexReadiness(response, signal) ? "home_not_ready" : "home_unreachable";
|
|
558
|
+
try { await response.body?.cancel(); } catch { /* the body is not needed */ }
|
|
559
|
+
if (response.status === 200) return "ready";
|
|
560
|
+
return response.status === 401 || response.status === 403 ? "unauthorized" : "home_unreachable";
|
|
561
|
+
} catch {
|
|
562
|
+
return "home_unreachable";
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
299
566
|
export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}): ClientLinkSupervisor {
|
|
300
|
-
const
|
|
301
|
-
const
|
|
567
|
+
const sidecarPath = (): string => clientLinkStatePath(deps.configDir);
|
|
568
|
+
const readSidecar = deps.readSidecar ?? readWhenFileChanges(sidecarPath, () => readClientLinkState(sidecarPath()));
|
|
569
|
+
const connectedLinkId = deps.connectedLinkId
|
|
570
|
+
?? readWhenFileChanges(getConfigPath, defaultConnectedLinkId, value => value !== CONNECTION_UNREADABLE);
|
|
302
571
|
const now = deps.now ?? (() => Date.now());
|
|
303
572
|
const random = deps.random ?? Math.random;
|
|
304
|
-
const
|
|
305
|
-
|
|
573
|
+
const policy = CLIENT_TUNNEL_RETRY_POLICY;
|
|
574
|
+
const isAlive = deps.isAlive ?? defaultIsAlive;
|
|
575
|
+
const linkKey = deps.linkKey ?? (() => null);
|
|
576
|
+
const ownerLookup = deps.ownsLoopbackListener ?? ownsIpv4LoopbackListener;
|
|
577
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
578
|
+
const setSupervisorTimer = deps.setTimer ?? ((callback: () => void, ms: number) => {
|
|
579
|
+
const interval = setInterval(callback, ms);
|
|
580
|
+
(interval as { unref?: () => void }).unref?.();
|
|
581
|
+
return interval as unknown as ReturnType<typeof setTimeout>;
|
|
582
|
+
});
|
|
306
583
|
const clearSupervisorTimer = deps.clearTimer
|
|
307
584
|
?? ((timer: ReturnType<typeof setTimeout>) => clearInterval(timer as unknown as ReturnType<typeof setInterval>));
|
|
308
585
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
@@ -312,27 +589,126 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
312
589
|
let initializing = false;
|
|
313
590
|
let onLinkEndedCalled = false;
|
|
314
591
|
let child: ClientLinkTunnelHandle | undefined;
|
|
592
|
+
/** A leftover tunnel this supervisor may not signal: watched, and replaced once it dies. */
|
|
593
|
+
let adopted: number | null = null;
|
|
594
|
+
let adoptedRecord: ClientTunnelPidfile | null = null;
|
|
595
|
+
let tunnelPort: number | null = null;
|
|
596
|
+
let ownerGeneration = 0;
|
|
315
597
|
let state: TunnelState = IDLE;
|
|
316
598
|
let linkId: string | null = null;
|
|
317
599
|
let failure: ClientLinkSupervisorStatus | undefined;
|
|
318
600
|
let tickFlight: Promise<void> | undefined;
|
|
601
|
+
/** The one keyed probe in flight, which tunnel it probes, and the abort that stop() fires. */
|
|
602
|
+
let probeFlight: { child: ClientLinkTunnelHandle | undefined; adopted: number | null; abort: AbortController } | undefined;
|
|
603
|
+
let probe: ClientTunnelProbeReason | null = null;
|
|
604
|
+
let nextProbeAt = 0;
|
|
605
|
+
let probeFailures = 0;
|
|
606
|
+
let invalidReads = 0;
|
|
607
|
+
/** A leftover tunnel pidfile has not been settled yet; nothing may spawn until it is. */
|
|
608
|
+
let reapPending = true;
|
|
609
|
+
const waiters = new Set<(connected: boolean) => void>();
|
|
610
|
+
|
|
611
|
+
/** The tunnel is being (re)established, so a request may wait for it instead of failing. */
|
|
612
|
+
const pending = (): boolean => !stopping && failure === undefined
|
|
613
|
+
&& ((started && !initialized) || state.kind === "connecting" || state.kind === "reconnecting");
|
|
614
|
+
const invalidateOwnerProof = (): void => {
|
|
615
|
+
ownerGeneration += 1;
|
|
616
|
+
};
|
|
617
|
+
const ownerCandidate = (port: number): { pid: number; generation: number } | null => {
|
|
618
|
+
if (port !== tunnelPort) return null;
|
|
619
|
+
const pid = child?.pid ?? adopted;
|
|
620
|
+
if (pid === null || pid === undefined || (adopted !== null && !adoptedRecord)) return null;
|
|
621
|
+
return { pid, generation: ownerGeneration };
|
|
622
|
+
};
|
|
623
|
+
const listenerOwnedByTunnel = async (port: number): Promise<boolean> => {
|
|
624
|
+
const candidate = ownerCandidate(port);
|
|
625
|
+
if (!candidate) return false;
|
|
626
|
+
if (adopted !== null) {
|
|
627
|
+
const record = adoptedRecord;
|
|
628
|
+
const identity = record ? await inspectAdoptedIdentity(record, deps) : "unknown";
|
|
629
|
+
if (identity !== "match") {
|
|
630
|
+
if (identity === "mismatch" && record && adopted === candidate.pid && ownerGeneration === candidate.generation) {
|
|
631
|
+
// The old PID may now name another process. Never signal it; discard only our stale
|
|
632
|
+
// pidfile and let the next supervisor tick launch a fresh tunnel.
|
|
633
|
+
abortProbe();
|
|
634
|
+
removePidfileIfPid(clientTunnelPidfilePath(deps.configDir), candidate.pid, record.startTime);
|
|
635
|
+
adopted = null;
|
|
636
|
+
adoptedRecord = null;
|
|
637
|
+
tunnelPort = null;
|
|
638
|
+
linkId = null;
|
|
639
|
+
probe = null;
|
|
640
|
+
invalidateOwnerProof();
|
|
641
|
+
setState(IDLE);
|
|
642
|
+
}
|
|
643
|
+
return false;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
const owned = await Promise.resolve().then(() => ownerLookup(port, candidate.pid)).catch(() => false);
|
|
647
|
+
return owned && !stopping && ownerGeneration === candidate.generation
|
|
648
|
+
&& ownerCandidate(port)?.pid === candidate.pid;
|
|
649
|
+
};
|
|
650
|
+
const connected = async (): Promise<boolean> => started && !stopping && failure === undefined
|
|
651
|
+
&& state.kind === "connected" && linkId !== null && tunnelPort !== null
|
|
652
|
+
&& await listenerOwnedByTunnel(tunnelPort);
|
|
653
|
+
|
|
654
|
+
const settleWaiters = (): void => {
|
|
655
|
+
if (waiters.size === 0) return;
|
|
656
|
+
const ready = !stopping && failure === undefined && state.kind === "connected";
|
|
657
|
+
if (!ready && pending()) return;
|
|
658
|
+
for (const settle of [...waiters]) settle(ready);
|
|
659
|
+
};
|
|
660
|
+
|
|
661
|
+
const setState = (next: TunnelState): void => {
|
|
662
|
+
state = next;
|
|
663
|
+
settleWaiters();
|
|
664
|
+
};
|
|
665
|
+
|
|
666
|
+
const awaitingReady = (): boolean => state.kind === "connecting" || state.kind === "reconnecting"
|
|
667
|
+
|| (state.kind === "failed" && state.inFlight === true);
|
|
319
668
|
|
|
320
669
|
const readCurrent = (): { sidecar: ClientLinkState | null; invalid: boolean } => {
|
|
321
670
|
try {
|
|
322
671
|
return { sidecar: readSidecar(), invalid: false };
|
|
323
|
-
} catch
|
|
672
|
+
} catch {
|
|
324
673
|
deps.warn?.("client link sidecar could not be read");
|
|
325
674
|
return { sidecar: null, invalid: true };
|
|
326
675
|
}
|
|
327
676
|
};
|
|
328
677
|
|
|
329
|
-
const
|
|
678
|
+
const readConnectedLinkId = (): string | null | typeof CONNECTION_UNREADABLE => {
|
|
679
|
+
try {
|
|
680
|
+
return connectedLinkId();
|
|
681
|
+
} catch {
|
|
682
|
+
return CONNECTION_UNREADABLE;
|
|
683
|
+
}
|
|
684
|
+
};
|
|
685
|
+
|
|
686
|
+
/** Drops the probe in flight; its answer, if it still comes, is ignored. */
|
|
687
|
+
const abortProbe = (): void => {
|
|
688
|
+
const flight = probeFlight;
|
|
689
|
+
probeFlight = undefined;
|
|
690
|
+
flight?.abort.abort();
|
|
691
|
+
};
|
|
692
|
+
|
|
693
|
+
/** Stops our own child only; the tunnel state is left for the caller to decide. */
|
|
694
|
+
const killChild = async (): Promise<void> => {
|
|
330
695
|
const current = child;
|
|
696
|
+
invalidateOwnerProof();
|
|
331
697
|
child = undefined;
|
|
332
|
-
|
|
698
|
+
probe = null;
|
|
699
|
+
abortProbe();
|
|
333
700
|
if (current) await current.stop();
|
|
334
701
|
};
|
|
335
702
|
|
|
703
|
+
const stopTunnel = async (): Promise<void> => {
|
|
704
|
+
invalidateOwnerProof();
|
|
705
|
+
adopted = null;
|
|
706
|
+
adoptedRecord = null;
|
|
707
|
+
tunnelPort = null;
|
|
708
|
+
setState(reduceTunnel(state, { type: "stop" }));
|
|
709
|
+
await killChild();
|
|
710
|
+
};
|
|
711
|
+
|
|
336
712
|
const endLink = async (): Promise<void> => {
|
|
337
713
|
await stopTunnel();
|
|
338
714
|
if (onLinkEndedCalled || stopping) return;
|
|
@@ -341,8 +717,14 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
341
717
|
deps.onLinkEnded?.();
|
|
342
718
|
};
|
|
343
719
|
|
|
720
|
+
const onExit = (stderrClass: StderrClass): void => {
|
|
721
|
+
setState(reduceTunnel(state, { type: "exit", now: now(), stderrClass }, random, policy));
|
|
722
|
+
};
|
|
723
|
+
|
|
344
724
|
const spawn = (sidecar: ClientLinkState): void => {
|
|
345
|
-
if (stopping || child ||
|
|
725
|
+
if (stopping || child || adopted !== null || reapPending) return;
|
|
726
|
+
const timestamp = now();
|
|
727
|
+
if (state.kind === "failed" && !dueForSpawn(state, timestamp)) return;
|
|
346
728
|
try {
|
|
347
729
|
child = spawnClientLinkTunnel({
|
|
348
730
|
linkId: sidecar.linkId,
|
|
@@ -350,53 +732,175 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
350
732
|
tunnelPort: sidecar.tunnelPort,
|
|
351
733
|
peerListenerPort: sidecar.peerListenerPort,
|
|
352
734
|
}, deps);
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
const current = child;
|
|
356
|
-
void current.exited.then(async () => {
|
|
357
|
-
if (child !== current) return;
|
|
358
|
-
child = undefined;
|
|
359
|
-
const stderr = (current as ClientLinkTunnelHandle & { stderr?: Promise<string> }).stderr
|
|
360
|
-
? await (current as ClientLinkTunnelHandle & { stderr?: Promise<string> }).stderr!.catch(() => "")
|
|
361
|
-
: "";
|
|
362
|
-
const next = reduceTunnel(state, { type: "exit", now: now(), stderrClass: classifySshStderr(stderr) }, random);
|
|
363
|
-
state = next;
|
|
364
|
-
}).catch(() => {
|
|
365
|
-
if (child !== current) return;
|
|
366
|
-
child = undefined;
|
|
367
|
-
state = reduceTunnel(state, { type: "exit", now: now(), stderrClass: "network" }, random);
|
|
368
|
-
});
|
|
369
|
-
} catch (error) {
|
|
370
|
-
state = { kind: "failed", since: now(), reason: "forward" };
|
|
735
|
+
} catch {
|
|
736
|
+
setState(failedTunnel("forward", timestamp, policy, state.kind === "failed" ? state.since : timestamp));
|
|
371
737
|
deps.warn?.("client link tunnel could not be started");
|
|
738
|
+
return;
|
|
372
739
|
}
|
|
740
|
+
invalidateOwnerProof();
|
|
741
|
+
linkId = sidecar.linkId;
|
|
742
|
+
tunnelPort = sidecar.tunnelPort;
|
|
743
|
+
probe = null;
|
|
744
|
+
probeFailures = 0;
|
|
745
|
+
nextProbeAt = timestamp;
|
|
746
|
+
setState(reduceTunnel(state, { type: "spawn", now: timestamp }, random, policy));
|
|
747
|
+
const current = child;
|
|
748
|
+
void current.exited.then(async () => {
|
|
749
|
+
if (child !== current) return;
|
|
750
|
+
invalidateOwnerProof();
|
|
751
|
+
child = undefined;
|
|
752
|
+
probe = null;
|
|
753
|
+
const stderr = (current as ClientLinkTunnelHandle & { stderr?: Promise<string> }).stderr
|
|
754
|
+
? await (current as ClientLinkTunnelHandle & { stderr?: Promise<string> }).stderr!.catch(() => "")
|
|
755
|
+
: "";
|
|
756
|
+
onExit(classifySshStderr(stderr));
|
|
757
|
+
}).catch(() => {
|
|
758
|
+
if (child !== current) return;
|
|
759
|
+
invalidateOwnerProof();
|
|
760
|
+
child = undefined;
|
|
761
|
+
probe = null;
|
|
762
|
+
onExit("network");
|
|
763
|
+
});
|
|
764
|
+
};
|
|
765
|
+
|
|
766
|
+
/**
|
|
767
|
+
* Applies one keyed probe's answer. While the tunnel is being established an answer that proves
|
|
768
|
+
* the link (`ready`, or `home_not_ready`) promotes it to connected; otherwise the next probe backs
|
|
769
|
+
* off from one to five seconds, or up to 30 seconds once the link reads failed (a revoked key or a
|
|
770
|
+
* stopped Home costs one probe per 30 s). While connected the answer is display-only, and the next
|
|
771
|
+
* probe is 30 seconds out.
|
|
772
|
+
*/
|
|
773
|
+
const applyProbe = (result: ProbeResult, wasConnected: boolean): void => {
|
|
774
|
+
const timestamp = now();
|
|
775
|
+
const reason = result === "ready" ? null : result;
|
|
776
|
+
if (wasConnected) {
|
|
777
|
+
if (state.kind === "connected") probe = reason;
|
|
778
|
+
nextProbeAt = timestamp + CONNECTED_PROBE_MS;
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if ((result === "ready" || result === "home_not_ready") && awaitingReady()) {
|
|
782
|
+
probe = reason;
|
|
783
|
+
probeFailures = 0;
|
|
784
|
+
nextProbeAt = timestamp + CONNECTED_PROBE_MS;
|
|
785
|
+
setState(reduceTunnel(state, { type: "ready", now: timestamp }, random, policy));
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
probe = reason;
|
|
789
|
+
probeFailures += 1;
|
|
790
|
+
const cap = state.kind === "failed" ? CONNECTED_PROBE_MS : PROBE_BACKOFF_MAX_MS;
|
|
791
|
+
nextProbeAt = timestamp + Math.min(cap, 1_000 * 2 ** Math.min(probeFailures - 1, 16));
|
|
792
|
+
};
|
|
793
|
+
|
|
794
|
+
/**
|
|
795
|
+
* Starts one keyed probe only after a local socket-owner check. A slow Home never delays the
|
|
796
|
+
* check that notices a disconnect, and stop() aborts it instead of waiting for probe timeout.
|
|
797
|
+
*/
|
|
798
|
+
const startProbe = (tunnelPort: number): void => {
|
|
799
|
+
const flight = { child, adopted, abort: new AbortController() };
|
|
800
|
+
probeFlight = flight;
|
|
801
|
+
const wasConnected = state.kind === "connected";
|
|
802
|
+
void (async () => {
|
|
803
|
+
if (!await listenerOwnedByTunnel(tunnelPort)) return;
|
|
804
|
+
if (probeFlight !== flight || stopping || flight.abort.signal.aborted
|
|
805
|
+
|| child !== flight.child || adopted !== flight.adopted) return;
|
|
806
|
+
const key = linkKey();
|
|
807
|
+
if (!key) return;
|
|
808
|
+
const result = await probeTunnel(fetchImpl, tunnelPort, key, flight.abort.signal);
|
|
809
|
+
// An aborted probe, or one whose listener changed while it ran, proves nothing.
|
|
810
|
+
if (probeFlight !== flight || stopping || child !== flight.child || adopted !== flight.adopted
|
|
811
|
+
|| !await listenerOwnedByTunnel(tunnelPort)) return;
|
|
812
|
+
applyProbe(result, wasConnected);
|
|
813
|
+
})().catch(() => undefined).finally(() => {
|
|
814
|
+
if (probeFlight === flight) probeFlight = undefined;
|
|
815
|
+
});
|
|
816
|
+
};
|
|
817
|
+
|
|
818
|
+
/**
|
|
819
|
+
* Settles a leftover tunnel pidfile once, before this supervisor's first spawn: a proven orphan
|
|
820
|
+
* is reaped and a live tunnel that may not be ours is watched without a keyed probe until its
|
|
821
|
+
* process identity and LISTEN ownership are verified. It needs a valid, matching read
|
|
822
|
+
* after the reap; without one it stays pending and the next check that has one runs it again.
|
|
823
|
+
*/
|
|
824
|
+
const settleLeftover = async (): Promise<void> => {
|
|
825
|
+
const orphan = await reapOrphanTunnel(deps);
|
|
826
|
+
if (stopping) return;
|
|
827
|
+
const afterReap = readCurrent();
|
|
828
|
+
if (afterReap.invalid || !afterReap.sidecar || readConnectedLinkId() !== afterReap.sidecar.linkId) return;
|
|
829
|
+
reapPending = false;
|
|
830
|
+
if ((orphan.tunnel === "owned" || orphan.tunnel === "unresolved") && orphan.pid !== undefined && isAlive(orphan.pid)) {
|
|
831
|
+
// A leftover tunnel that may not be ours to stop: watch it, probe only after identity and
|
|
832
|
+
// socket ownership match, and start our own once it dies. It is never signalled.
|
|
833
|
+
adopted = orphan.pid;
|
|
834
|
+
const record = readPidfile(clientTunnelPidfilePath(deps.configDir));
|
|
835
|
+
const identity = record?.pid === adopted && record.linkId === afterReap.sidecar.linkId
|
|
836
|
+
? tunnelIdentity(record, deps.platform ?? process.platform, deps, isAlive) : null;
|
|
837
|
+
adoptedRecord = identity?.kind === "ours" ? record : null;
|
|
838
|
+
invalidateOwnerProof();
|
|
839
|
+
linkId = afterReap.sidecar.linkId;
|
|
840
|
+
tunnelPort = afterReap.sidecar.tunnelPort;
|
|
841
|
+
nextProbeAt = now();
|
|
842
|
+
setState({ kind: "connecting", since: now() });
|
|
843
|
+
return;
|
|
844
|
+
}
|
|
845
|
+
spawn(afterReap.sidecar);
|
|
373
846
|
};
|
|
374
847
|
|
|
375
848
|
const tick = async (): Promise<void> => {
|
|
376
849
|
if (stopping || !initialized) return;
|
|
377
850
|
const current = readCurrent();
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
851
|
+
const connected = current.invalid ? null : readConnectedLinkId();
|
|
852
|
+
if (current.invalid || connected === CONNECTION_UNREADABLE) {
|
|
853
|
+
// A single unreadable read (a write in progress, a transient I/O error) must not end a
|
|
854
|
+
// healthy link; only INVALID_READ_TICKS in a row do.
|
|
855
|
+
invalidReads += 1;
|
|
856
|
+
if (invalidReads < INVALID_READ_TICKS) return;
|
|
857
|
+
if (current.invalid) {
|
|
858
|
+
failure = { kind: "failed", reason: "sidecar_invalid" };
|
|
859
|
+
await stopTunnel();
|
|
860
|
+
} else if (child || linkId || adopted !== null) {
|
|
861
|
+
await endLink();
|
|
862
|
+
}
|
|
863
|
+
settleWaiters();
|
|
381
864
|
return;
|
|
382
865
|
}
|
|
383
|
-
|
|
384
|
-
if (!current.sidecar) {
|
|
385
|
-
if (child || linkId) await endLink();
|
|
866
|
+
invalidReads = 0;
|
|
867
|
+
if (!current.sidecar || connected !== current.sidecar.linkId) {
|
|
868
|
+
if (child || linkId || adopted !== null) await endLink();
|
|
386
869
|
return;
|
|
387
870
|
}
|
|
388
|
-
|
|
389
|
-
|
|
871
|
+
failure = undefined;
|
|
872
|
+
const sidecar = current.sidecar;
|
|
873
|
+
if (reapPending) {
|
|
874
|
+
// The start-up read was unreadable or did not match, so a leftover tunnel was never
|
|
875
|
+
// settled. Settle it now, before anything spawns, so an orphan cannot keep the port.
|
|
876
|
+
await settleLeftover();
|
|
877
|
+
settleWaiters();
|
|
390
878
|
return;
|
|
391
879
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
880
|
+
if (adopted !== null && !isAlive(adopted)) {
|
|
881
|
+
// The leftover tunnel is gone: start our own on this tick.
|
|
882
|
+
invalidateOwnerProof();
|
|
883
|
+
adopted = null;
|
|
884
|
+
adoptedRecord = null;
|
|
885
|
+
tunnelPort = null;
|
|
886
|
+
probe = null;
|
|
887
|
+
setState(IDLE);
|
|
888
|
+
}
|
|
889
|
+
setState(reduceTunnel(state, { type: "tick", now: now() }, random, policy));
|
|
890
|
+
// An adopted tunnel is never killed. Failed identity or socket-owner proof keeps it
|
|
891
|
+
// unprobed and unable to promote, even when the process stays alive after a timeout.
|
|
892
|
+
if (state.kind === "failed" && !state.inFlight && adopted !== null) setState({ ...state, inFlight: true });
|
|
893
|
+
if (state.kind === "failed" && !state.inFlight && child) await killChild();
|
|
894
|
+
// A probe still running for a tunnel that has since exited or died answers nothing useful.
|
|
895
|
+
if (probeFlight && (probeFlight.child !== child || probeFlight.adopted !== adopted)) abortProbe();
|
|
896
|
+
// While requests are held the backoff does not apply: one probe per check (one a second), so a
|
|
897
|
+
// forward that just came up releases them at once. Held requests exist only while it is down.
|
|
898
|
+
if ((child || adopted !== null) && (state.kind === "connected" || awaitingReady()) && !probeFlight
|
|
899
|
+
&& (now() >= nextProbeAt || waiters.size > 0)) {
|
|
900
|
+
startProbe(sidecar.tunnelPort);
|
|
397
901
|
}
|
|
398
|
-
if (state.kind === "
|
|
399
|
-
|
|
902
|
+
if (!child && adopted === null && (state.kind === "idle" || dueForSpawn(state, now()))) spawn(sidecar);
|
|
903
|
+
settleWaiters();
|
|
400
904
|
};
|
|
401
905
|
|
|
402
906
|
const runTick = (): void => {
|
|
@@ -407,27 +911,21 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
407
911
|
const initialize = async (): Promise<void> => {
|
|
408
912
|
if (initializing || initialized || stopping) return;
|
|
409
913
|
initializing = true;
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
initializing = false;
|
|
415
|
-
return;
|
|
416
|
-
}
|
|
417
|
-
if (current.sidecar && connectedLinkId() === current.sidecar.linkId) {
|
|
418
|
-
const orphan = await reapOrphanTunnel(deps);
|
|
419
|
-
if (orphan.tunnel === "owned" || orphan.tunnel === "unresolved") {
|
|
420
|
-
linkId = current.sidecar.linkId;
|
|
421
|
-
state = { kind: "connected", since: now() };
|
|
422
|
-
initialized = true;
|
|
423
|
-
initializing = false;
|
|
914
|
+
try {
|
|
915
|
+
const current = readCurrent();
|
|
916
|
+
if (current.invalid) {
|
|
917
|
+
failure = { kind: "failed", reason: "sidecar_invalid" };
|
|
424
918
|
return;
|
|
425
919
|
}
|
|
426
|
-
|
|
427
|
-
|
|
920
|
+
// An unreadable or mismatched read leaves the leftover tunnel for the first check that
|
|
921
|
+
// reads a matching link (reapPending), so no tunnel is ever spawned over an unreaped one.
|
|
922
|
+
if (!current.sidecar || readConnectedLinkId() !== current.sidecar.linkId) return;
|
|
923
|
+
await settleLeftover();
|
|
924
|
+
} finally {
|
|
925
|
+
initialized = true;
|
|
926
|
+
initializing = false;
|
|
927
|
+
settleWaiters();
|
|
428
928
|
}
|
|
429
|
-
initialized = true;
|
|
430
|
-
initializing = false;
|
|
431
929
|
};
|
|
432
930
|
|
|
433
931
|
return {
|
|
@@ -440,6 +938,7 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
440
938
|
failure = { kind: "failed", reason: "sidecar_invalid" };
|
|
441
939
|
initialized = true;
|
|
442
940
|
initializing = false;
|
|
941
|
+
settleWaiters();
|
|
443
942
|
});
|
|
444
943
|
},
|
|
445
944
|
async stop(): Promise<void> {
|
|
@@ -448,6 +947,8 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
448
947
|
return;
|
|
449
948
|
}
|
|
450
949
|
stopping = true;
|
|
950
|
+
abortProbe();
|
|
951
|
+
settleWaiters();
|
|
451
952
|
if (timer !== undefined) {
|
|
452
953
|
clearSupervisorTimer(timer);
|
|
453
954
|
timer = undefined;
|
|
@@ -458,8 +959,36 @@ export function createClientLinkSupervisor(deps: ClientLinkSupervisorDeps = {}):
|
|
|
458
959
|
},
|
|
459
960
|
status(): ClientLinkSupervisorStatus {
|
|
460
961
|
if (failure) return failure;
|
|
461
|
-
if (!child && !linkId) return { kind: "stopped" };
|
|
462
|
-
return {
|
|
962
|
+
if (!child && !linkId && adopted === null) return { kind: "stopped" };
|
|
963
|
+
return {
|
|
964
|
+
kind: "tunnel",
|
|
965
|
+
linkId: linkId ?? "",
|
|
966
|
+
state,
|
|
967
|
+
pid: child?.pid ?? adopted,
|
|
968
|
+
...(probe ? { probe } : {}),
|
|
969
|
+
};
|
|
970
|
+
},
|
|
971
|
+
pending,
|
|
972
|
+
connected,
|
|
973
|
+
waitForConnected(timeoutMs: number, signal?: AbortSignal): Promise<boolean> {
|
|
974
|
+
if (state.kind === "connected") return connected();
|
|
975
|
+
if (!pending() || waiters.size >= CLIENT_LINK_MAX_HOLDS || signal?.aborted || !(timeoutMs > 0)) {
|
|
976
|
+
return Promise.resolve(false);
|
|
977
|
+
}
|
|
978
|
+
return new Promise<boolean>(resolve => {
|
|
979
|
+
let holdTimer: ReturnType<typeof setTimeout> | undefined;
|
|
980
|
+
const onAbort = (): void => settle(false);
|
|
981
|
+
const settle = (connected: boolean): void => {
|
|
982
|
+
if (!waiters.delete(settle)) return;
|
|
983
|
+
if (holdTimer !== undefined) clearTimeout(holdTimer);
|
|
984
|
+
signal?.removeEventListener("abort", onAbort);
|
|
985
|
+
resolve(connected);
|
|
986
|
+
};
|
|
987
|
+
waiters.add(settle);
|
|
988
|
+
holdTimer = setTimeout(() => settle(false), timeoutMs);
|
|
989
|
+
(holdTimer as { unref?: () => void }).unref?.();
|
|
990
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
991
|
+
});
|
|
463
992
|
},
|
|
464
993
|
};
|
|
465
994
|
}
|