mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(ipc): keep the peer inbox reachable, and say so when it is not (#10800)
* feat(ipc): keep the peer inbox reachable, and say so when it is not A session with cross-session messaging on binds a UNIX domain socket, and that bind could fail invisibly. The socket directory was $XDG_RUNTIME_DIR/qwen-socks/ and nothing else; inside a rootless container or a user namespace that directory is often absent, owned by an unmapped uid, or read-only. The session started anyway, and the only symptom was peers reporting it absent — a diagnosis available only from the other side of the problem. Binding now walks a chain of candidates — the runtime directory, then a private nonce-named directory under the temp directory, then one under /tmp — and gives up only when every one fails. When they all do, the failure is classified (not a directory, foreign owner, permission, missing parent, path too long, bind error, chmod error) with one line naming what to change, and kept where the user can see it: the session prints it once at startup and /peers repeats it instead of pointing at DEBUG=1. A successful bind clears it. Three hardening items ride along. A connection must complete a line within 30 s of connecting and then within 30 s of its last complete line, so a peer dribbling one byte at a time under the 1 MiB cap can no longer hold a descriptor for the life of the session; the old timer was an idle timer that any byte reset. Sockets left by killed sessions are swept at bind time — dead <pid>.sock files in the shared runtime directory, and whole nonce-named directories holding nothing but dead sockets — with a file removed only when its PID is provably dead and a directory only when it is ours, matches the exact name shape, and contains nothing else. And the bind no longer takes over a live address. It unlinked whatever sat at its path first, on the grounds that the path is keyed by PID: "if a live process were listening there, it would be this one." That holds only within one PID namespace. Two containers sharing a runtime directory, or one sharing the host's, can produce two live sessions with the same PID, and the second to start would unlink the first's socket — leaving it listening on an inode no peer can reach while it believed it was fine. The bind now dials the path first: a socket that answers is left alone and this session binds <pid>-<8 hex>.sock beside it, while only a socket that does not answer is unlinked. A listen that still reports EADDRINUSE retries once at a sibling name. Senders are unaffected — a peer's address comes from its registry record, never from its PID. * fix(ipc): require a definitive verdict before deleting a peer socket Reviewer round 1 on #10800. R1-2 / R1-25: `probePeerSocket`'s boolean served four call sites with opposite requirements. The two sweeps and the pre-bind probe read `true` as "do not delete", while `listMessageablePeers` reads it as "advertise this peer" -- so adding EMFILE/ENFILE to the alive set made descriptor exhaustion advertise unreachable twins, and a timed-out probe still settled `false` and licensed an unlink of a live listener from another namespace. Split the answer: `probePeerSocketVerdict` returns alive/dead/unknown, with the deadline, EMFILE/ENFILE, permission errors and any unenumerated errno mapping to `unknown`. Both sweeps and the pre-bind probe now require a definitive `dead`, and re-read `isPidAlive` on the turn of the unlink so the verdict cannot straddle the probe's round trip. `listMessageablePeers` admits only `alive`. `probePeerSocket` is kept as the boolean collapse for read-only callers. R1-39: any complete line re-armed the line deadline, including one that failed to parse, so a peer writing `x\n` per deadline could hold a connection -- and one of the 64 maxConnections slots -- for the whole session. `arm()` now runs after a frame parses; the auth line keeps its own re-arm. R1-3: the candidate loop overwrote `failure` each iteration, so the user was shown a process-minted nonce directory instead of the path they configured, and machine-level refusals were counted as candidate attempts. Report the first candidate's diagnosis, keep counting all of them, and stop at `unsupported_platform` with attempts: 1. R1-5: a failed sibling retry classified the original path while Node's message named the sibling. Track the path last handed to `listen`. R1-29: drop a socket directory this call minted when the bind then failed -- no sweep can reach it, since `sweepAround` runs only on the success path. R1-27 / R1-19 / R1-28 / R1-15 / R1-14 / R1-36: test fixes -- observe the shutdown so `close()` unlinking the wrong path is caught, replace a silent `return` with a visible skip and build the fixture from a short root, walk the candidate chain for the sibling-overflow fall-through, name TMPDIR instead of letting the environment pick it, replace two copies of a hand-rolled env helper with `vi.stubEnv`, and make the `/peers` stub mirror the real renderer instead of a cause token production never emits. * fix(ipc): give the sibling-overflow failure its own cause and sentence R1-4: one condition -- the PID-keyed path is held by a live session and the `<pid>-<8hex>.sock` sibling would not fit `sun_path` -- had two producers reporting it differently, and the renderer made a false claim about it. The probe branch classified it `path_too_long` and dropped `detail`, so the user read that a 33-byte path was over a 103-byte limit: something they can measure and disprove, with the one clause explaining the failure discarded. The raced-`EADDRINUSE` branch hit the same condition, fell through `classify` (which has no EADDRINUSE case) to `bind_failed`, and advised waiting for a long-lived twin that will never exit and taking ownership of a directory already theirs. Both now go through one `siblingOverflowFailure` helper and a dedicated `sibling_too_long` cause, whose sentence names the real blocker. * test(ipc): pin the ownership guard and the directory sweep's three guards R1-9: no test made a real bind produce `foreign_owner` -- every `startPeerInbox` call binds inside a `mkdtemp` directory the test user owns, so both the cause label and the `dirStat.uid !== uid` guard were unpinned. That guard is what stops the chmod below it from retargeting a directory another user planted, and what keeps "belongs to another user" distinguishable from "cannot create or lock down". Added a case that moves the comparison value with `vi.spyOn(process, 'getuid')`, so it fires whether or not the runner is root. R1-26: `sweepOrphanSocketDirs` recursively deletes directories in a world-shared temp dir, but each fixture that should survive did so for the wrong reason -- the empty ones through the 60-second grace, the populated ones through a live listener -- leaving the name-shape filter, the self-exclusion and the PID liveness check individually deletable with the suite green. Rebuilt the fixture so each survivor survives for exactly one reason: an aged non-nonce directory (name shape), an aged selfDir holding only a dead socket (self-exclusion), and an aged nonce directory holding a socket file named for this live process with nothing listening (liveness). * fix(ipc): type the getuid spy so the build stops failing `vi.spyOn(process, 'getuid')` narrows to `never` because the Node types mark `getuid` optional -- it does not exist on Windows -- so `.mockReturnValue` did not type-check and `Build cli` went red on the ownership-guard test added in the previous commit. Narrow the receiver before spying. * fix(ipc): name the configured path when a bind fails, and pin the branches that had no test A failure reported after the bind moved aside to a sibling named the `<pid>-<8hex>.sock` this process had minted seconds earlier, which contradicted `PeerInboxStartFailure.socketPath`'s own contract and sent the user to inspect a filename that exists in none of their configuration. Every failure inside `bindAt` is now classified against the requested path; the name actually attempted stays in the errno detail, where a log reader wants it. The contract is extended to say so, since it previously justified only the fallback directories. Four branches shipped with no test driving them, each of which stayed green under mutation: - the multi-candidate walk keeping the FIRST candidate's diagnosis - the socket chmod failure, its `chmod_failed` cause and its cleanup - the raced-EADDRINUSE sibling overflow - the raced retry's reported path The last two need `node:fs/promises` and `node:net` stubbed -- a socket `chmod` fails in a directory this process just chmod'd and owns, and a sibling name is eight random bytes with nothing plantable in its way -- so they live in a file of their own rather than putting module-level mocks under sixty real-socket tests. Also pins the candidate-chain fixture to the real resolver, so a longer `SOCKET_DIR_NAME` fails loudly instead of retiring the guard it carries, and corrects the design doc, which still called a sibling overflow `path_too_long`. * test(ipc): close the deferred coverage gaps in the inbox and its sweeps Six findings the last round recorded but did not request. All six were mutants that survived, or fixtures resting on a premise that is not true. - `missing_ancestor` had neither a producer nor a rendering assertion. `mkdir` is recursive, so ENOENT cannot come from there -- but `mkdir`, `lstat` and `chmod` are three syscalls, and a sweeper removing the directory in between makes the next one fail. The stub reproduces that window; without the ENOENT case the failure renders as `unknown`. - `sweepAround`'s directory-name scope guard had no test. Removing it let a bind at an explicit path delete `<pid>.sock` files out of a directory this code never created, with the suite green. The new test carries its own positive control, so a wait too short to observe any sweep cannot pass it by doing nothing. - The directory sweep's uid guard had no test either: removing it swept a directory belonging to another user. - 4194303 is `pid_max - 1`, which is allocatable and on a busy machine eventually allocated, so every "nothing owns this PID" fixture rested on a premise the kernel can falsify. They now use 2^31-1, above every `pid_max` the kernel accepts, named once as a constant. - The multi-batch sweep fixture spanned the PID its own self-path used once SWEEP_BATCH_SIZE passed 44, which would have failed the test for a reason unrelated to batching. The self path now sits outside the range, asserted rather than assumed. - `resolvePeerSocketPath` returns the first candidate, which since the candidate chain landed is no longer the address the inbox binds. Its doc still claimed otherwise; it now says so, and points a caller after a running session's address at the registry record instead. * test(ipc): harden peer socket regressions * test(ipc): share the stale socket fixture * fix(ipc): address peer inbox review findings --------- Co-authored-by: 易良 <[email protected]> Co-authored-by: Shaojin Wen <[email protected]> Co-authored-by: qwen-code-dev-bot <[email protected]> Co-authored-by: qqqys <[email protected]>
This commit is contained in:
20 files changed
+2861
-176
No files matched your search
@@ -0,0 +1,128 @@
|
||||
# Keeping the peer inbox reachable
|
||||
|
||||
Date: 2026-09-02
|
||||
Scope: `packages/core/src/ipc` (socket path resolution, inbox bind), and the
|
||||
CLI surfaces that report a session's messaging state.
|
||||
|
||||
## Problem
|
||||
|
||||
A session with `agents.crossSessionMessaging` on registers itself and binds
|
||||
a UNIX domain socket. Both steps can fail, and the failure was invisible:
|
||||
|
||||
1. **No fallback.** The socket directory was `$XDG_RUNTIME_DIR/qwen-socks/`
|
||||
and nothing else. In a rootless container or a user namespace that
|
||||
directory is often absent, owned by an unmapped uid, or read-only. The
|
||||
bind failed, the session started anyway, and the only symptom was other
|
||||
sessions reporting it absent — a diagnosis available only from the other
|
||||
side of the problem.
|
||||
2. **No explanation.** The bind error went to a debug log that is off by
|
||||
default. `/peers` blamed either registration or the socket without
|
||||
knowing which, and pointed at `DEBUG=1`.
|
||||
3. **A connection could be held open indefinitely.** The read timer was an
|
||||
idle timer that any byte reset, so a peer writing one byte at a time
|
||||
stayed under the 1 MiB frame cap and kept a descriptor for the life of
|
||||
the session. Recorded as a deferred finding when the inbox landed.
|
||||
4. **Dead sockets accumulated.** A session killed with `SIGKILL` cannot
|
||||
unlink its own socket and nothing else did.
|
||||
5. **A live session could be made unreachable by another one.** The bind
|
||||
unlinked whatever sat at its path first, justified by the path being
|
||||
keyed by PID: "if a live process were listening there, it would be this
|
||||
one." That holds only within one PID namespace. Two containers sharing a
|
||||
runtime directory, or a container sharing the host's, can produce two
|
||||
live sessions with the same PID; the second to start would unlink the
|
||||
first's socket, leaving it listening on an unreachable inode and
|
||||
believing it was fine.
|
||||
|
||||
## Design
|
||||
|
||||
**Candidate chain.** `resolvePeerSocketCandidates` returns every path this
|
||||
process could bind, best first: `$XDG_RUNTIME_DIR/qwen-socks/`, then a
|
||||
nonce-named private directory under the temp directory, then one under
|
||||
`/tmp`. The bind walks the chain and gives up only when all of them fail.
|
||||
Candidates over the `sun_path` limit are dropped before they are tried.
|
||||
|
||||
The nonce matters outside the runtime directory: a shared temp directory is
|
||||
a place where another user can pre-create a fixed or uid-derived directory
|
||||
name and lock us out, or where our own chmod could retarget a directory
|
||||
they own. An unpredictable name avoids both, and costs nothing because
|
||||
peers learn the address from the session registry rather than deriving it.
|
||||
|
||||
**Classified failure with a hint.** When every candidate fails the error is
|
||||
mapped onto a cause a user can act on — not a directory, foreign owner,
|
||||
permission, missing ancestor, path too long, sibling name too long, bind
|
||||
failure, chmod failure — each with one line naming what to change. The
|
||||
failure is kept in module state so two surfaces can show it: the session
|
||||
prints it once at startup, and `/peers` repeats it instead of pointing at a
|
||||
debug flag. A successful bind clears it.
|
||||
|
||||
**A deadline, not an idle timer.** A connection must complete a line within
|
||||
30 s of connecting, and then within 30 s of its last complete line. Only a
|
||||
whole line re-arms it, so byte-at-a-time dribbling no longer holds a
|
||||
descriptor. A sender writes its frame and hangs up, so a legitimate
|
||||
connection never approaches this.
|
||||
|
||||
**Sweep at bind time.** A starting session removes `<pid>.sock` files whose
|
||||
PID is provably dead, and whole nonce-named directories holding nothing but
|
||||
dead sockets. Conservative by construction: a live PID is left alone even
|
||||
though it may have been recycled, because a leftover file costs bytes while
|
||||
deleting a live session's socket costs its reachability. Bind time is the
|
||||
natural moment — the directory only grows when a session dies.
|
||||
|
||||
**Probe before unlink; move aside, never take over.** The bind now dials
|
||||
the path before clearing it. If something answers, or the probe cannot
|
||||
conclusively prove the path dead, this session binds `<pid>-<8 hex>.sock`
|
||||
beside it instead. Only a socket proven dead is unlinked. If
|
||||
`listen()` still reports `EADDRINUSE` — the path was taken between the
|
||||
probe and the listen — one retry at a sibling name settles it, and a second
|
||||
failure is reported as a real bind failure. When a sibling name would
|
||||
exceed `sun_path` the candidate fails as `sibling_too_long` — its own cause
|
||||
and its own sentence, deliberately not `path_too_long`, because the
|
||||
requested candidate itself fits and telling the user it is over a limit they
|
||||
can measure is a claim they can disprove — rather than binding something
|
||||
unusable, and the chain moves on.
|
||||
|
||||
A failure always names the first candidate: derived from `XDG_RUNTIME_DIR`
|
||||
when it is set, or from a tmpdir nonce otherwise. It never names a later
|
||||
fallback or sibling; the name actually attempted stays in the errno detail.
|
||||
|
||||
## Trade-offs
|
||||
|
||||
- **A sibling address is not the PID-derived one.** Nothing in the codebase
|
||||
derives a peer's address from its PID — peers read `ipcPath` from the
|
||||
registry record — so this is invisible to senders. It does mean the
|
||||
filename is no longer a reliable index of which PID owns a socket, which
|
||||
is why the sweep parses the PID out of the prefix and matches the suffix
|
||||
shape exactly.
|
||||
- **The probe costs a dial on every start.** It is a local connect against
|
||||
a path that is usually absent, and it buys the guarantee that starting a
|
||||
session never disconnects another one.
|
||||
- **A live PID is never swept.** PID recycling means a live PID does not
|
||||
prove the socket is ours, so leftovers can survive until the directory is
|
||||
cleaned by the OS. Preferred over any chance of deleting a live inbox.
|
||||
- **The startup notice is an error line in the transcript.** It is one line,
|
||||
once, only when messaging is on and the inbox failed. A session that
|
||||
looks enabled but is unreachable is worse.
|
||||
- **30 s is arbitrary but generous.** It is three orders of magnitude above
|
||||
what a real send needs.
|
||||
|
||||
## Files
|
||||
|
||||
- `packages/core/src/ipc/socket-path.ts` — candidate chain, nonce
|
||||
directories, `sun_path` filtering.
|
||||
- `packages/core/src/ipc/uds-inbox.ts` — failure causes and descriptions,
|
||||
candidate walk, probe-before-unlink and sibling binding, line deadline,
|
||||
both sweeps.
|
||||
- `packages/core/src/ipc/uds-client.ts` — probe used by the sweeps and the
|
||||
bind.
|
||||
- `packages/core/src/ipc/uds-inbox-bind-failures.test.ts` — the two bind
|
||||
failures no filesystem fixture can produce (a socket `chmod` that fails
|
||||
after `listen()` succeeded, and a `listen()` that fails at both names),
|
||||
kept apart so their module-level stubs stay off the real-socket suite.
|
||||
- `packages/cli/src/peerMessaging/PeerMessagingContext.tsx` — failure
|
||||
context, kept apart from the inbox so a null inbox can be told from a
|
||||
failed one.
|
||||
- `packages/cli/src/ui/startInteractiveUI.tsx` — captures the failure when
|
||||
the inbox resolves null with the feature on.
|
||||
- `packages/cli/src/ui/AppContainer.tsx` — prints it once at startup.
|
||||
- `packages/cli/src/ui/commands/peers-command.ts` — repeats it in `/peers`.
|
||||
- `docs/users/features/commands.md` — user-facing description.
|
||||
@@ -835,6 +835,20 @@ A hold does not wait forever. A message nobody decides on expires after
|
||||
minutes by default — and the sending session is told that no decision
|
||||
came. Shortening the setting applies to messages already waiting.
|
||||
|
||||
If the session cannot bind its inbox — the runtime directory is missing,
|
||||
owned by another user, or read-only, as it can be inside a container —
|
||||
it first tries a private directory under the temp directory, and only
|
||||
if that fails too does it start without one. When that happens the
|
||||
session says so at startup, and `/peers` repeats the reason and what to
|
||||
change (usually `XDG_RUNTIME_DIR` or `TMPDIR`).
|
||||
|
||||
Two sessions can also resolve the same inbox address, because the address
|
||||
is keyed by process id and process ids repeat across containers that share
|
||||
a runtime directory. The session starting second takes a neighbouring
|
||||
address instead of taking over the one in use, so neither becomes
|
||||
unreachable. Peers are unaffected: they read a session's address from the
|
||||
session registry rather than deriving it.
|
||||
|
||||
The `send_message` call only confirms the message was handed to the other
|
||||
session. What became of it arrives later as a receipt: if it was held,
|
||||
declined, refused, expired, or misaddressed (the address changed hands —
|
||||
|
||||
@@ -5,7 +5,19 @@
|
||||
*/
|
||||
|
||||
import { createContext, useContext } from 'react';
|
||||
import type { PeerInboxStartFailure } from '@qwen-code/qwen-code-core';
|
||||
import type { PeerMessaging } from './peer-messaging.js';
|
||||
|
||||
export const PeerMessagingContext = createContext<PeerMessaging | null>(null);
|
||||
export const usePeerMessaging = () => useContext(PeerMessagingContext);
|
||||
|
||||
/**
|
||||
* Why this session has no inbox although cross-session messaging is on.
|
||||
*
|
||||
* Null while the inbox is still binding, when it bound, and when the
|
||||
* feature is off; set once the bind has failed for good. Kept apart from
|
||||
* the inbox itself so a null inbox can be told apart from a failed one.
|
||||
*/
|
||||
export const PeerInboxFailureContext =
|
||||
createContext<PeerInboxStartFailure | null>(null);
|
||||
export const usePeerInboxFailure = () => useContext(PeerInboxFailureContext);
|
||||
@@ -193,6 +193,7 @@ async function flushConfigInitialization() {
|
||||
// value swappable without wrapping every render in a provider.
|
||||
const peerMessagingHolder = vi.hoisted(() => ({
|
||||
current: null as unknown,
|
||||
failure: null as unknown,
|
||||
}));
|
||||
vi.mock('../peerMessaging/PeerMessagingContext.js', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -202,6 +203,7 @@ vi.mock('../peerMessaging/PeerMessagingContext.js', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
usePeerMessaging: () => peerMessagingHolder.current,
|
||||
usePeerInboxFailure: () => peerMessagingHolder.failure,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -7935,6 +7937,48 @@ describe('AppContainer State Management', () => {
|
||||
expect(addPeerMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('announces once, with the cause, when the inbox could not bind', () => {
|
||||
const addItem = mockedUseHistory().addItem as Mock;
|
||||
const failure = {
|
||||
cause: 'foreign_owner',
|
||||
socketPath: '/run/user/1000/qwen-socks/1.sock',
|
||||
detail: 'belongs to uid 65534, not 1000',
|
||||
hint: 'Set XDG_RUNTIME_DIR to a directory you own, then restart.',
|
||||
attempts: 3,
|
||||
};
|
||||
peerMessagingHolder.failure = failure;
|
||||
try {
|
||||
const { rerender } = render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
peerMessagingHolder.failure = { ...failure };
|
||||
rerender(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
settings={mockSettings}
|
||||
version="1.0.0"
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
const notices = addItem.mock.calls
|
||||
.map((call) => call[0] as { type?: string; text?: string })
|
||||
.filter((item) =>
|
||||
item.text?.includes('Cross-session messaging is OFF'),
|
||||
);
|
||||
expect(notices).toHaveLength(1);
|
||||
expect(notices[0]?.type).toBe(MessageType.ERROR);
|
||||
expect(notices[0]?.text).toContain('belongs to another user');
|
||||
expect(notices[0]?.text).toContain('XDG_RUNTIME_DIR');
|
||||
} finally {
|
||||
peerMessagingHolder.failure = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('announces held and denied receipts, and delivery only after a hold', () => {
|
||||
const addItem = mockedUseHistory().addItem as Mock;
|
||||
const peer = makePeerMessaging();
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
describeDeliveryStatus,
|
||||
parseHeldExpiry,
|
||||
describeHoldCause,
|
||||
describePeerInboxFailure,
|
||||
getErrorMessage,
|
||||
getAllMemoryFilenames,
|
||||
ShellExecutionService,
|
||||
@@ -257,7 +258,10 @@ import { useContextualTips } from './hooks/useContextualTips.js';
|
||||
import { getTipHistory } from '../services/tips/index.js';
|
||||
import { restorePromptStash } from '../services/prompt-stash.js';
|
||||
import { useRemoteInput } from '../remoteInput/RemoteInputContext.js';
|
||||
import { usePeerMessaging } from '../peerMessaging/PeerMessagingContext.js';
|
||||
import {
|
||||
usePeerInboxFailure,
|
||||
usePeerMessaging,
|
||||
} from '../peerMessaging/PeerMessagingContext.js';
|
||||
import {
|
||||
MAX_ACCEPTED_BACKLOG,
|
||||
type PeerMessaging,
|
||||
@@ -2708,6 +2712,24 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
});
|
||||
}, [historyManager, peerMessaging]);
|
||||
|
||||
// Say so when the inbox could not bind. With the feature on, a session
|
||||
// without an inbox is unreachable, and its only other symptom is peers
|
||||
// reporting it absent — a problem the user would otherwise discover
|
||||
// from the wrong side. One line, once, with the cause and what to do.
|
||||
const peerInboxFailure = usePeerInboxFailure();
|
||||
const announcedInboxFailureRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!peerInboxFailure || announcedInboxFailureRef.current) return;
|
||||
announcedInboxFailureRef.current = true;
|
||||
historyManager.addItem(
|
||||
{
|
||||
type: MessageType.ERROR,
|
||||
text: `Cross-session messaging is OFF for this session — the inbox could not bind: ${describePeerInboxFailure(peerInboxFailure)}`,
|
||||
},
|
||||
Date.now(),
|
||||
);
|
||||
}, [historyManager, peerInboxFailure]);
|
||||
|
||||
// A held message may only be waiting on a mode mismatch, so re-run the
|
||||
// gate whenever the approval mode changes rather than making the user
|
||||
// approve something the new mode would have accepted outright.
|
||||
|
||||
@@ -11,8 +11,27 @@ import type { HeldMessage } from '@qwen-code/qwen-code-core';
|
||||
// from core, and pulling the barrel in drags the whole module graph
|
||||
// behind it. The wording assertions below only depend on these stubs; the
|
||||
// stubs mirror the real helpers, whose behavior is pinned by core's own
|
||||
// tests (peer-envelope.test.ts, peer-frames.test.ts).
|
||||
// tests (peer-envelope.test.ts, peer-frames.test.ts, and for the inbox
|
||||
// failure renderer, uds-inbox.test.ts).
|
||||
const inboxFailure = vi.hoisted(() => ({
|
||||
current: null as null | {
|
||||
cause: string;
|
||||
socketPath: string;
|
||||
detail: string;
|
||||
hint: string;
|
||||
attempts: number;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', () => ({
|
||||
getLastPeerInboxFailure: () => inboxFailure.current,
|
||||
// Mirrors the real renderer's `foreign_owner` branch (uds-inbox.ts).
|
||||
// The previous stub interpolated `failure.cause`, a machine token the
|
||||
// real helper emits for no cause at all, so the assertion below could
|
||||
// not have failed against anything production produces.
|
||||
describePeerInboxFailure: (failure: { socketPath: string; hint: string }) =>
|
||||
// The real branch renders the socket's *directory*, not the socket.
|
||||
`"${failure.socketPath.replace(/\/[^/]*$/, '')}" belongs to another user. ${failure.hint}`,
|
||||
describeHoldCause: (cause: string) =>
|
||||
cause === 'mode-mismatch'
|
||||
? 'this session can apply some actions without per-action review and the sender does not'
|
||||
@@ -321,12 +340,36 @@ describe('/peers', () => {
|
||||
it('does not tell a user to enable a setting they already enabled', async () => {
|
||||
// Same null inbox, different cause: registration or the bind failed.
|
||||
// "Turn it on" would send them back to a setting that is already on.
|
||||
inboxFailure.current = null;
|
||||
const result = await run(null, '', true);
|
||||
expect(result.messageType).toBe('error');
|
||||
expect(result.content).toContain('failed to bind');
|
||||
expect(result.content).toContain('failed to register');
|
||||
expect(result.content).not.toContain('Enable it with');
|
||||
});
|
||||
|
||||
it('repeats the bind failure and what to change when the inbox could not bind', async () => {
|
||||
inboxFailure.current = {
|
||||
cause: 'foreign_owner',
|
||||
socketPath: '/run/user/1000/qwen-socks/1.sock',
|
||||
detail: 'belongs to uid 65534',
|
||||
hint: 'Set XDG_RUNTIME_DIR to a directory you own, then restart.',
|
||||
attempts: 3,
|
||||
};
|
||||
try {
|
||||
const result = await run(null, '', true);
|
||||
expect(result.messageType).toBe('error');
|
||||
expect(result.content).toContain('failed to bind its socket');
|
||||
// The prose a user actually sees, matching AppContainer.test.tsx's
|
||||
// assertion for the same fixture -- not a cause token that only
|
||||
// ever existed in this file's stub.
|
||||
expect(result.content).toContain('belongs to another user');
|
||||
expect(result.content).toContain('XDG_RUNTIME_DIR');
|
||||
expect(result.content).not.toContain('Enable it with');
|
||||
} finally {
|
||||
inboxFailure.current = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('lists held messages by default', async () => {
|
||||
messages = [held({ msgId: 'aaaaaa11-0000-4000-8000-000000000000' })];
|
||||
expect((await run(fake, '')).content).toContain('1 message waiting');
|
||||
|
||||
@@ -17,7 +17,9 @@
|
||||
import {
|
||||
canonicalizeMsgId,
|
||||
describeHoldCause,
|
||||
describePeerInboxFailure,
|
||||
flattenPeerLabel,
|
||||
getLastPeerInboxFailure,
|
||||
type HeldMessage,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import type { SlashCommand, SlashCommandActionReturn } from './types.js';
|
||||
@@ -178,12 +180,15 @@ export const peersCommand: SlashCommand = {
|
||||
const enabled =
|
||||
context.services.settings?.merged?.agents?.crossSessionMessaging ===
|
||||
true;
|
||||
const failure = enabled ? getLastPeerInboxFailure() : null;
|
||||
return {
|
||||
type: 'message',
|
||||
messageType: enabled ? 'error' : 'info',
|
||||
content: enabled
|
||||
? 'Cross-session messaging is on, but this session has no inbox: it either failed to register in the session registry or failed to bind its socket. Re-run with DEBUG=1 to see the bind error.'
|
||||
: 'Cross-session messaging is off. Enable it with "agents.crossSessionMessaging": true in settings.json, then restart.',
|
||||
content: !enabled
|
||||
? 'Cross-session messaging is off. Enable it with "agents.crossSessionMessaging": true in settings.json, then restart.'
|
||||
: failure
|
||||
? `Cross-session messaging is on, but this session has no inbox — it failed to bind its socket: ${describePeerInboxFailure(failure)}`
|
||||
: 'Cross-session messaging is on, but this session has no inbox: it failed to register in the session registry, or the inbox is still starting. Re-run with DEBUG=1 to see the registration error.',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
@@ -11,12 +13,19 @@
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { render as renderDom, waitFor } from '@testing-library/react';
|
||||
import type { ReactElement } from 'react';
|
||||
import type { Config } from '@qwen-code/qwen-code-core';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import type { InitializationResult } from '../core/initializer.js';
|
||||
|
||||
const registerSession = vi.hoisted(() => vi.fn());
|
||||
const registerCleanup = vi.hoisted(() => vi.fn());
|
||||
const inkRender = vi.hoisted(() => vi.fn());
|
||||
const lastPeerInboxFailure = vi.hoisted(() => ({ value: null as unknown }));
|
||||
const observedPeerInboxFailure = vi.hoisted(() => ({
|
||||
value: null as unknown,
|
||||
}));
|
||||
|
||||
vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
const actual =
|
||||
@@ -24,13 +33,83 @@ vi.mock('@qwen-code/qwen-code-core', async (importOriginal) => {
|
||||
return {
|
||||
...actual,
|
||||
registerSession: (...args: unknown[]) => registerSession(...args),
|
||||
getLastPeerInboxFailure: () => lastPeerInboxFailure.value,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('ink', () => ({
|
||||
render: vi.fn(() => ({ unmount: vi.fn() })),
|
||||
render: (element: ReactElement) => {
|
||||
inkRender(element);
|
||||
return { unmount: vi.fn() };
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('./AppContainer.js', async () => {
|
||||
const React = await import('react');
|
||||
const { usePeerInboxFailure } = await import(
|
||||
'../peerMessaging/PeerMessagingContext.js'
|
||||
);
|
||||
return {
|
||||
AppContainer: () => {
|
||||
observedPeerInboxFailure.value = usePeerInboxFailure();
|
||||
return React.createElement('div');
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./contexts/KeypressContext.js', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
KeypressProvider: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./contexts/SessionContext.js', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
SessionStatsProvider: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./contexts/VimModeContext.js', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
VimModeProvider: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./contexts/AgentViewContext.js', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
AgentViewProvider: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./contexts/BackgroundTaskViewContext.js', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
BackgroundTaskViewProvider: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('./hooks/useKittyKeyboardProtocol.js', () => ({
|
||||
useKittyKeyboardProtocol: () => ({ enabled: false }),
|
||||
}));
|
||||
|
||||
vi.mock('./components/shared/ErrorBoundary.js', async () => {
|
||||
const React = await import('react');
|
||||
return {
|
||||
ErrorBoundary: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
consumeLastRenderError: () => null,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../utils/cleanup.js', () => ({
|
||||
registerCleanup: (...args: unknown[]) => registerCleanup(...args),
|
||||
runExitCleanup: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -169,6 +248,8 @@ describe('startInteractiveUI cross-session messaging', () => {
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
lastPeerInboxFailure.value = null;
|
||||
observedPeerInboxFailure.value = null;
|
||||
registerSession.mockResolvedValue(true);
|
||||
peerMessagingStart.mockResolvedValue({
|
||||
close: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -325,6 +406,30 @@ describe('startInteractiveUI cross-session messaging', () => {
|
||||
expect(peerMessagingStart).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('provides the recorded bind failure when inbox startup fails', async () => {
|
||||
const failure = {
|
||||
cause: 'permission',
|
||||
socketPath: '/run/user/1000/qwen-socks/1.sock',
|
||||
detail: 'EACCES',
|
||||
hint: 'Choose a directory you own.',
|
||||
attempts: 3,
|
||||
};
|
||||
lastPeerInboxFailure.value = failure;
|
||||
peerMessagingStart.mockResolvedValue(null);
|
||||
|
||||
await start(makeConfig(), enabledSettings);
|
||||
await vi.waitFor(() => expect(peerMessagingStart).toHaveBeenCalled());
|
||||
const appTree = inkRender.mock.calls[0]?.[0] as ReactElement;
|
||||
const mounted = renderDom(appTree);
|
||||
try {
|
||||
await waitFor(() =>
|
||||
expect(observedPeerInboxFailure.value).toEqual(failure),
|
||||
);
|
||||
} finally {
|
||||
mounted.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
it('closes the inbox from exit cleanup', async () => {
|
||||
const close = vi.fn().mockResolvedValue(undefined);
|
||||
peerMessagingStart.mockResolvedValue({ close });
|
||||
|
||||
@@ -10,15 +10,20 @@ import { render } from 'ink';
|
||||
import React from 'react';
|
||||
import {
|
||||
createDebugLogger,
|
||||
getLastPeerInboxFailure,
|
||||
type InboundPolicy,
|
||||
parseHeldExpiry,
|
||||
type PeerInboxStartFailure,
|
||||
isDebugLogFileEnabled,
|
||||
registerSession,
|
||||
type Config,
|
||||
writeRuntimeStatus,
|
||||
} from '@qwen-code/qwen-code-core';
|
||||
import { PeerMessaging } from '../peerMessaging/peer-messaging.js';
|
||||
import { PeerMessagingContext } from '../peerMessaging/PeerMessagingContext.js';
|
||||
import {
|
||||
PeerInboxFailureContext,
|
||||
PeerMessagingContext,
|
||||
} from '../peerMessaging/PeerMessagingContext.js';
|
||||
import type { LoadedSettings } from '../config/settings.js';
|
||||
import { isValidSessionId } from '../config/config.js';
|
||||
import type { InitializationResult } from '../core/initializer.js';
|
||||
@@ -200,10 +205,21 @@ export async function startInteractiveUI(
|
||||
// listening where no peer can reach it.
|
||||
const [peerMessaging, setPeerMessaging] =
|
||||
React.useState<PeerMessaging | null>(null);
|
||||
const [peerInboxFailure, setPeerInboxFailure] =
|
||||
React.useState<PeerInboxStartFailure | null>(null);
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
void peerMessagingReady.then((messaging) => {
|
||||
if (alive) setPeerMessaging(messaging);
|
||||
if (!alive) return;
|
||||
setPeerMessaging(messaging);
|
||||
// A null inbox with the feature on is a bind that failed; the
|
||||
// cause is what the user needs, not the null.
|
||||
if (
|
||||
messaging === null &&
|
||||
settings.merged.agents?.crossSessionMessaging === true
|
||||
) {
|
||||
setPeerInboxFailure(getLastPeerInboxFailure());
|
||||
}
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
@@ -211,44 +227,48 @@ export async function startInteractiveUI(
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PeerMessagingContext.Provider value={peerMessaging}>
|
||||
<RemoteInputContext.Provider value={remoteInputWatcher}>
|
||||
<DualOutputContext.Provider value={dualOutputBridge}>
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider
|
||||
kittyProtocolEnabled={kittyProtocolStatus.enabled}
|
||||
config={config}
|
||||
debugKeystrokeLogging={
|
||||
settings.merged.general?.debugKeystrokeLogging
|
||||
}
|
||||
pasteWorkaround={
|
||||
process.platform === 'win32' || nodeMajorVersion < 20
|
||||
}
|
||||
initialCapturedInput={initialCapturedInput}
|
||||
>
|
||||
<SessionStatsProvider sessionId={config.getSessionId()}>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AgentViewProvider config={config}>
|
||||
<BackgroundTaskViewProvider config={config}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
settings={settings}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
initializationResult={initializationResult}
|
||||
initialUseVirtualViewport={useVP}
|
||||
extensionRefreshState={options.extensionRefreshState}
|
||||
repaintViewport={resizeReflow.repaint}
|
||||
/>
|
||||
</BackgroundTaskViewProvider>
|
||||
</AgentViewProvider>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
</DualOutputContext.Provider>
|
||||
</RemoteInputContext.Provider>
|
||||
</PeerMessagingContext.Provider>
|
||||
<PeerInboxFailureContext.Provider value={peerInboxFailure}>
|
||||
<PeerMessagingContext.Provider value={peerMessaging}>
|
||||
<RemoteInputContext.Provider value={remoteInputWatcher}>
|
||||
<DualOutputContext.Provider value={dualOutputBridge}>
|
||||
<SettingsContext.Provider value={settings}>
|
||||
<KeypressProvider
|
||||
kittyProtocolEnabled={kittyProtocolStatus.enabled}
|
||||
config={config}
|
||||
debugKeystrokeLogging={
|
||||
settings.merged.general?.debugKeystrokeLogging
|
||||
}
|
||||
pasteWorkaround={
|
||||
process.platform === 'win32' || nodeMajorVersion < 20
|
||||
}
|
||||
initialCapturedInput={initialCapturedInput}
|
||||
>
|
||||
<SessionStatsProvider sessionId={config.getSessionId()}>
|
||||
<VimModeProvider settings={settings}>
|
||||
<AgentViewProvider config={config}>
|
||||
<BackgroundTaskViewProvider config={config}>
|
||||
<AppContainer
|
||||
config={config}
|
||||
settings={settings}
|
||||
startupWarnings={startupWarnings}
|
||||
version={version}
|
||||
initializationResult={initializationResult}
|
||||
initialUseVirtualViewport={useVP}
|
||||
extensionRefreshState={
|
||||
options.extensionRefreshState
|
||||
}
|
||||
repaintViewport={resizeReflow.repaint}
|
||||
/>
|
||||
</BackgroundTaskViewProvider>
|
||||
</AgentViewProvider>
|
||||
</VimModeProvider>
|
||||
</SessionStatsProvider>
|
||||
</KeypressProvider>
|
||||
</SettingsContext.Provider>
|
||||
</DualOutputContext.Provider>
|
||||
</RemoteInputContext.Provider>
|
||||
</PeerMessagingContext.Provider>
|
||||
</PeerInboxFailureContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -7,13 +7,14 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
const listLiveSessions = vi.fn();
|
||||
const probePeerSocket = vi.fn();
|
||||
const probePeerSocketVerdict = vi.fn();
|
||||
|
||||
vi.mock('../services/session-registry.js', () => ({
|
||||
listLiveSessions: (...args: unknown[]) => listLiveSessions(...args),
|
||||
}));
|
||||
vi.mock('./uds-client.js', () => ({
|
||||
probePeerSocket: (...args: unknown[]) => probePeerSocket(...args),
|
||||
probePeerSocketVerdict: (...args: unknown[]) =>
|
||||
probePeerSocketVerdict(...args),
|
||||
}));
|
||||
|
||||
const {
|
||||
@@ -56,8 +57,8 @@ function record(over: Record<string, unknown>) {
|
||||
|
||||
beforeEach(() => {
|
||||
listLiveSessions.mockReset();
|
||||
probePeerSocket.mockReset();
|
||||
probePeerSocket.mockResolvedValue(true);
|
||||
probePeerSocketVerdict.mockReset();
|
||||
probePeerSocketVerdict.mockResolvedValue('alive');
|
||||
});
|
||||
|
||||
describe('peerRef', () => {
|
||||
@@ -119,7 +120,7 @@ describe('listMessageablePeers', () => {
|
||||
]);
|
||||
const peers = await listMessageablePeers();
|
||||
expect(peers.map((p) => p.sessionId)).toEqual(['s1']);
|
||||
expect(probePeerSocket).toHaveBeenCalledTimes(1);
|
||||
expect(probePeerSocketVerdict).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('collapses one session id hosted by two live processes', async () => {
|
||||
@@ -171,8 +172,8 @@ describe('listMessageablePeers', () => {
|
||||
startedAt: 2_000,
|
||||
}),
|
||||
]);
|
||||
probePeerSocket.mockImplementation(async (path: string) =>
|
||||
path.endsWith('a.sock'),
|
||||
probePeerSocketVerdict.mockImplementation(async (path: string) =>
|
||||
path.endsWith('a.sock') ? 'alive' : 'dead',
|
||||
);
|
||||
|
||||
const peers = await listMessageablePeers();
|
||||
@@ -181,6 +182,37 @@ describe('listMessageablePeers', () => {
|
||||
expect(peers[0]).toMatchObject({ sessionId: 'shared', pid: 100 });
|
||||
});
|
||||
|
||||
it('lets an answering twin outlive a newer one whose probe was inconclusive', async () => {
|
||||
// Descriptor exhaustion is reachable here: the probes above are an
|
||||
// uncapped Promise.all over every record, in a process already holding
|
||||
// the UI's, MCP servers' and children's fds. If `unknown` read as
|
||||
// reachable, the newer non-answering twin would win the `startedAt`
|
||||
// tie-break and the one session that could receive the message would
|
||||
// be the one dropped.
|
||||
listLiveSessions.mockResolvedValue([
|
||||
record({
|
||||
sessionId: 'shared',
|
||||
pid: 100,
|
||||
ipcPath: '/tmp/answering.sock',
|
||||
startedAt: 1_000,
|
||||
}),
|
||||
record({
|
||||
sessionId: 'shared',
|
||||
pid: 101,
|
||||
ipcPath: '/tmp/inconclusive.sock',
|
||||
startedAt: 2_000,
|
||||
}),
|
||||
]);
|
||||
probePeerSocketVerdict.mockImplementation(async (path: string) =>
|
||||
path.endsWith('answering.sock') ? 'alive' : 'unknown',
|
||||
);
|
||||
|
||||
const peers = await listMessageablePeers();
|
||||
|
||||
expect(peers).toHaveLength(1);
|
||||
expect(peers[0]).toMatchObject({ pid: 100 });
|
||||
});
|
||||
|
||||
it('keeps differently named incarnations of one session id', async () => {
|
||||
// Names are cwd-derived, so `qwen --resume <id>` from another directory
|
||||
// (or a later `/cd`) registers a second, differently named process.
|
||||
@@ -227,8 +259,8 @@ describe('listMessageablePeers', () => {
|
||||
record({ sessionId: 's1', ipcPath: '/tmp/s1.sock' }),
|
||||
record({ sessionId: 's2', ipcPath: '/tmp/s2.sock' }),
|
||||
]);
|
||||
probePeerSocket.mockImplementation(async (path: string) =>
|
||||
path.endsWith('s1.sock'),
|
||||
probePeerSocketVerdict.mockImplementation(async (path: string) =>
|
||||
path.endsWith('s1.sock') ? 'alive' : 'dead',
|
||||
);
|
||||
|
||||
const peers = await listMessageablePeers();
|
||||
@@ -243,7 +275,7 @@ describe('listMessageablePeers', () => {
|
||||
]);
|
||||
let inFlight = 0;
|
||||
let peak = 0;
|
||||
probePeerSocket.mockImplementation(async () => {
|
||||
probePeerSocketVerdict.mockImplementation(async () => {
|
||||
inFlight += 1;
|
||||
peak = Math.max(peak, inFlight);
|
||||
await new Promise((resolve) => setTimeout(resolve, 5));
|
||||
@@ -258,7 +290,7 @@ describe('listMessageablePeers', () => {
|
||||
it('returns an empty list when nothing is registered', async () => {
|
||||
listLiveSessions.mockResolvedValue([]);
|
||||
expect(await listMessageablePeers()).toEqual([]);
|
||||
expect(probePeerSocket).not.toHaveBeenCalled();
|
||||
expect(probePeerSocketVerdict).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import {
|
||||
type SessionRegistryRecord,
|
||||
} from '../services/session-registry.js';
|
||||
import { flattenPeerLabel } from './peer-envelope.js';
|
||||
import { probePeerSocket } from './uds-client.js';
|
||||
import { probePeerSocketVerdict } from './uds-client.js';
|
||||
|
||||
/** A live session that can be sent to. */
|
||||
export interface PeerSessionInfo {
|
||||
@@ -97,9 +97,15 @@ export async function listMessageablePeers(): Promise<PeerSessionInfo[]> {
|
||||
.map(toPeerSessionInfo)
|
||||
.filter((peer): peer is PeerSessionInfo => peer !== null);
|
||||
|
||||
const reachable = await Promise.all(
|
||||
candidates.map((peer) => probePeerSocket(peer.ipcPath)),
|
||||
const verdicts = await Promise.all(
|
||||
candidates.map((peer) => probePeerSocketVerdict(peer.ipcPath)),
|
||||
);
|
||||
// Only a definitive `alive` advertises a peer. An `unknown` verdict --
|
||||
// local descriptor exhaustion, a permission error, the 250 ms deadline --
|
||||
// establishes nothing about the peer, and admitting it here would let a
|
||||
// newer, unreachable twin shadow the one that answers in the dedupe
|
||||
// below, whose tie-break is `startedAt` alone.
|
||||
const reachable = verdicts.map((verdict) => verdict === 'alive');
|
||||
return dedupeSameNameTwins(candidates.filter((_, index) => reachable[index]));
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as os from 'node:os';
|
||||
import {
|
||||
isLocalIpcPath,
|
||||
resolvePeerSocketCandidates,
|
||||
MAX_SOCKET_PATH_BYTES,
|
||||
resolvePeerSocketPath,
|
||||
} from './socket-path.js';
|
||||
@@ -21,6 +22,9 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// packages/core's vitest config does not set `unstubEnvs`, so a test
|
||||
// that throws before its own cleanup would otherwise leak its stubs.
|
||||
vi.unstubAllEnvs();
|
||||
if (originalRuntimeDir === undefined) {
|
||||
delete process.env['XDG_RUNTIME_DIR'];
|
||||
} else {
|
||||
@@ -104,3 +108,66 @@ describe('isLocalIpcPath', () => {
|
||||
expect(isLocalIpcPath(42 as unknown as string)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(isWindows)('resolvePeerSocketCandidates', () => {
|
||||
it('lists the runtime directory first, then a nonce directory under tmpdir, then /tmp', () => {
|
||||
vi.stubEnv('XDG_RUNTIME_DIR', '/run/user/1000');
|
||||
vi.stubEnv('TMPDIR', '/var/tmp');
|
||||
try {
|
||||
const candidates = resolvePeerSocketCandidates(42);
|
||||
expect(candidates[0]).toBe('/run/user/1000/qwen-socks/42.sock');
|
||||
expect(candidates[1]).toMatch(
|
||||
/^\/var\/tmp\/qwen-socks-[0-9a-f]{16}\/42\.sock$/,
|
||||
);
|
||||
expect(candidates[2]).toMatch(
|
||||
/^\/tmp\/qwen-socks-[0-9a-f]{16}\/42\.sock$/,
|
||||
);
|
||||
// The same nonce for both fallbacks: one session, one directory name.
|
||||
expect(candidates[1]!.split('/')[3]).toBe(candidates[2]!.split('/')[2]);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it('drops candidates that cannot fit in sun_path', () => {
|
||||
vi.stubEnv('XDG_RUNTIME_DIR', '/' + 'r'.repeat(120));
|
||||
vi.stubEnv('TMPDIR', undefined);
|
||||
try {
|
||||
const candidates = resolvePeerSocketCandidates(42);
|
||||
expect(candidates.some((c) => c.startsWith('/rrr'))).toBe(false);
|
||||
expect(candidates.every((c) => Buffer.byteLength(c) <= 103)).toBe(true);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it('deduplicates the /tmp fallback', () => {
|
||||
// TMPDIR is named, not unset: deleting it does not make os.tmpdir()
|
||||
// return /tmp, it only moves libuv on to TMP, then TEMP. A shell
|
||||
// exporting either (Nix and Guix shells, some container images) would
|
||||
// yield three distinct candidates and fail this assertion without any
|
||||
// change to the resolver. Construct the duplicate rather than hope
|
||||
// the environment supplies it.
|
||||
vi.stubEnv('XDG_RUNTIME_DIR', '/run/user/1000');
|
||||
vi.stubEnv('TMPDIR', '/tmp');
|
||||
try {
|
||||
const candidates = resolvePeerSocketCandidates(42);
|
||||
expect(candidates).toHaveLength(2);
|
||||
expect(new Set(candidates).size).toBe(candidates.length);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it('drops an over-long temp-dir candidate and keeps the short /tmp one', () => {
|
||||
vi.stubEnv('XDG_RUNTIME_DIR', undefined);
|
||||
vi.stubEnv('TMPDIR', '/' + 't'.repeat(200));
|
||||
try {
|
||||
const candidates = resolvePeerSocketCandidates(42);
|
||||
expect(candidates).toHaveLength(1);
|
||||
expect(candidates.at(-1)).toMatch(/^\/tmp\/qwen-socks-/);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -27,7 +27,15 @@ export const MAX_SOCKET_PATH_BYTES = 103;
|
||||
export const SOCKET_DIR_NAME = 'qwen-socks';
|
||||
|
||||
/**
|
||||
* Resolve this process's socket path.
|
||||
* The socket path this process would prefer.
|
||||
*
|
||||
* The first candidate, not the address the inbox ends up on: the bind
|
||||
* walks the whole chain and may settle on a fallback directory or on a
|
||||
* `<pid>-<8hex>.sock` sibling. Nothing in this repository calls it, and
|
||||
* nothing should read a running session's address from it -- peers take
|
||||
* `ipcPath` from the session registry record. Kept exported, and pinned
|
||||
* by its own suite, for callers outside this package that only want the
|
||||
* preference.
|
||||
*
|
||||
* Prefers `$XDG_RUNTIME_DIR` — a per-user tmpfs that the OS cleans up on
|
||||
* logout, which is exactly the lifetime a session socket wants. Already
|
||||
@@ -42,23 +50,40 @@ export const SOCKET_DIR_NAME = 'qwen-socks';
|
||||
* long to bind.
|
||||
*/
|
||||
export function resolvePeerSocketPath(pid: number = process.pid): string {
|
||||
return resolvePeerSocketCandidates(pid)[0]!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Every place this process could bind its socket, best first.
|
||||
*
|
||||
* The runtime directory is preferred but is not always usable: in a
|
||||
* rootless container or a user namespace it can be absent, owned by an
|
||||
* unmapped uid, or mounted read-only, and a session whose inbox silently
|
||||
* fails there is a session nobody can reach and nobody knows why. So the
|
||||
* bind tries these in order and only gives up when the last one fails.
|
||||
* Candidates over the `sun_path` limit are dropped up front.
|
||||
*/
|
||||
export function resolvePeerSocketCandidates(
|
||||
pid: number = process.pid,
|
||||
): string[] {
|
||||
const candidates: string[] = [];
|
||||
const runtimeDir = process.env['XDG_RUNTIME_DIR'];
|
||||
if (runtimeDir) {
|
||||
const preferred = path.join(runtimeDir, SOCKET_DIR_NAME, `${pid}.sock`);
|
||||
if (Buffer.byteLength(preferred) <= MAX_SOCKET_PATH_BYTES) {
|
||||
return preferred;
|
||||
}
|
||||
candidates.push(path.join(runtimeDir, SOCKET_DIR_NAME, `${pid}.sock`));
|
||||
}
|
||||
|
||||
const nonce = randomBytes(8).toString('hex');
|
||||
const fallback = path.join(
|
||||
os.tmpdir(),
|
||||
`${SOCKET_DIR_NAME}-${nonce}`,
|
||||
`${pid}.sock`,
|
||||
candidates.push(
|
||||
path.join(os.tmpdir(), `${SOCKET_DIR_NAME}-${nonce}`, `${pid}.sock`),
|
||||
);
|
||||
candidates.push(
|
||||
path.join('/tmp', `${SOCKET_DIR_NAME}-${nonce}`, `${pid}.sock`),
|
||||
);
|
||||
if (Buffer.byteLength(fallback) <= MAX_SOCKET_PATH_BYTES) return fallback;
|
||||
|
||||
return path.join('/tmp', `${SOCKET_DIR_NAME}-${nonce}`, `${pid}.sock`);
|
||||
return candidates.filter(
|
||||
(candidate, index) =>
|
||||
Buffer.byteLength(candidate) <= MAX_SOCKET_PATH_BYTES &&
|
||||
candidates.indexOf(candidate) === index,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,6 +8,16 @@
|
||||
* `probePeerSocket` against a real socket for the reachable/stale cases,
|
||||
* and against a scripted `net.connect` for the errno and deadline rules
|
||||
* that a real socket cannot be made to produce on demand.
|
||||
*
|
||||
* The verdict suite below carries the three-valued answer. The boolean
|
||||
* suite covers `probePeerSocket`, which no caller in this repository uses
|
||||
* any more: it predates the verdict, and the one consumer it had -- the
|
||||
* read-only peer listing -- moved to `probePeerSocketVerdict` so its own
|
||||
* tests could tell `unknown` from `dead`. The wrapper stays exported for
|
||||
* callers outside this repository, and its suite is what pins the collapse
|
||||
* they get. Kept apart from the verdict suite because the interesting
|
||||
* cases are exactly the ones where the two disagree -- `unknown` and
|
||||
* `dead` both read as `false`.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
@@ -15,6 +25,7 @@ import { EventEmitter } from 'node:events';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { leaveStaleSocket } from '../test-utils/stale-socket.js';
|
||||
|
||||
type ConnectImpl = (...args: unknown[]) => unknown;
|
||||
let connectImpl: ConnectImpl | null = null;
|
||||
@@ -31,7 +42,8 @@ vi.mock('node:net', async () => {
|
||||
return { ...real, default: { ...real, connect }, connect };
|
||||
});
|
||||
|
||||
const { probePeerSocket, PROBE_TIMEOUT_MS } = await import('./uds-client.js');
|
||||
const { probePeerSocket, probePeerSocketVerdict, PROBE_TIMEOUT_MS } =
|
||||
await import('./uds-client.js');
|
||||
const { startPeerInbox } = await import('./uds-inbox.js');
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
@@ -43,6 +55,28 @@ function scriptedSocket(): EventEmitter & { destroy: () => void } {
|
||||
return socket;
|
||||
}
|
||||
|
||||
/** Drive one `connect` failure through the probe, for errnos a real
|
||||
* socket cannot be made to produce on demand. */
|
||||
function scriptErrno(code: string): void {
|
||||
const socket = scriptedSocket();
|
||||
connectImpl = () => {
|
||||
queueMicrotask(() =>
|
||||
socket.emit('error', Object.assign(new Error(code), { code })),
|
||||
);
|
||||
return socket;
|
||||
};
|
||||
}
|
||||
|
||||
async function probeVerdictWithError(code: string) {
|
||||
scriptErrno(code);
|
||||
return probePeerSocketVerdict('/tmp/scripted.sock');
|
||||
}
|
||||
|
||||
async function probeBooleanWithError(code: string) {
|
||||
scriptErrno(code);
|
||||
return probePeerSocket('/tmp/scripted.sock');
|
||||
}
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -77,7 +111,7 @@ describe.skipIf(isWindows)('probePeerSocket', () => {
|
||||
it('is false for a stale socket file left by a dead process', async () => {
|
||||
// A leftover inode stats fine; only the dial (ECONNREFUSED) says it is dead.
|
||||
const stale = path.join(tmpDir, 'stale.sock');
|
||||
await fs.writeFile(stale, '');
|
||||
await leaveStaleSocket(stale);
|
||||
expect(await probePeerSocket(stale)).toBe(false);
|
||||
});
|
||||
|
||||
@@ -87,22 +121,12 @@ describe.skipIf(isWindows)('probePeerSocket', () => {
|
||||
expect(connectCalls).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('counts a full listen backlog as alive, and every other errno as dead', async () => {
|
||||
const probeWithError = async (code: string) => {
|
||||
const socket = scriptedSocket();
|
||||
connectImpl = () => {
|
||||
queueMicrotask(() =>
|
||||
socket.emit('error', Object.assign(new Error(code), { code })),
|
||||
);
|
||||
return socket;
|
||||
};
|
||||
return probePeerSocket('/tmp/scripted.sock');
|
||||
};
|
||||
expect(await probeWithError('EAGAIN')).toBe(true);
|
||||
expect(await probeWithError('EBUSY')).toBe(true);
|
||||
expect(await probeWithError('ECONNREFUSED')).toBe(false);
|
||||
expect(await probeWithError('ENOENT')).toBe(false);
|
||||
expect(await probeWithError('EACCES')).toBe(false);
|
||||
it('is false for every answer short of a listener, conclusive or not', async () => {
|
||||
// The collapse this consumer wants: only `alive` advertises a peer as
|
||||
// messageable, so an inconclusive probe reads the same as a dead one.
|
||||
expect(await probeBooleanWithError('EAGAIN')).toBe(true);
|
||||
expect(await probeBooleanWithError('EMFILE')).toBe(false);
|
||||
expect(await probeBooleanWithError('ECONNREFUSED')).toBe(false);
|
||||
});
|
||||
|
||||
it('gives up after PROBE_TIMEOUT_MS when the listener never accepts', async () => {
|
||||
@@ -120,3 +144,59 @@ describe.skipIf(isWindows)('probePeerSocket', () => {
|
||||
expect(socket.destroy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(isWindows)('probePeerSocketVerdict', () => {
|
||||
it('is alive only when a listener answers or the backlog is full', async () => {
|
||||
const inbox = await startPeerInbox({
|
||||
socketPath: path.join(tmpDir, 'socks', 'live.sock'),
|
||||
onFrame: () => {},
|
||||
});
|
||||
if (!inbox) throw new Error('inbox failed to start');
|
||||
try {
|
||||
expect(await probePeerSocketVerdict(inbox.socketPath)).toBe('alive');
|
||||
} finally {
|
||||
await inbox.close();
|
||||
}
|
||||
// A saturated listen backlog is a busy session, not a dead one.
|
||||
expect(await probeVerdictWithError('EAGAIN')).toBe('alive');
|
||||
expect(await probeVerdictWithError('EBUSY')).toBe('alive');
|
||||
});
|
||||
|
||||
it('is dead only when the dial reached the path and found nothing', async () => {
|
||||
expect(await probePeerSocketVerdict(path.join(tmpDir, 'gone.sock'))).toBe(
|
||||
'dead',
|
||||
);
|
||||
// A leftover inode stats fine; only the dial (ECONNREFUSED) says it is
|
||||
// dead, and this is the one verdict that licenses an unlink.
|
||||
const stale = path.join(tmpDir, 'stale.sock');
|
||||
await leaveStaleSocket(stale);
|
||||
expect(await probePeerSocketVerdict(stale)).toBe('dead');
|
||||
});
|
||||
|
||||
it('is unknown when the probe establishes nothing about the peer', async () => {
|
||||
// Descriptor exhaustion and permission errors are about this process,
|
||||
// not the peer; an unenumerated errno has not been reasoned about at
|
||||
// all. None of them may be spent as proof of death.
|
||||
expect(await probeVerdictWithError('EMFILE')).toBe('unknown');
|
||||
expect(await probeVerdictWithError('ENFILE')).toBe('unknown');
|
||||
expect(await probeVerdictWithError('EACCES')).toBe('unknown');
|
||||
expect(await probeVerdictWithError('EPERM')).toBe('unknown');
|
||||
expect(await probeVerdictWithError('EPROTOTYPE')).toBe('unknown');
|
||||
// Nothing was dialled at all here, so nothing was established.
|
||||
expect(await probePeerSocketVerdict('relative/peer.sock')).toBe('unknown');
|
||||
});
|
||||
|
||||
it('is unknown, not dead, when the deadline wins', async () => {
|
||||
// The case the sweep turns on: a slow peer and a stalled prober are
|
||||
// indistinguishable from here, so the timeout must not delete anything.
|
||||
vi.useFakeTimers();
|
||||
const socket = scriptedSocket();
|
||||
connectImpl = () => socket;
|
||||
let settled: string | null = null;
|
||||
void probePeerSocketVerdict('/tmp/hung.sock').then((verdict) => {
|
||||
settled = verdict;
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(PROBE_TIMEOUT_MS);
|
||||
expect(settled).toBe('unknown');
|
||||
});
|
||||
});
|
||||
@@ -209,37 +209,85 @@ export async function sendDeliveryStatus(
|
||||
}
|
||||
|
||||
/**
|
||||
* True when something is listening on `socketPath`.
|
||||
* What a probe of `socketPath` established.
|
||||
*
|
||||
* A full listen backlog (EAGAIN on POSIX, EBUSY on Windows named pipes)
|
||||
* counts as alive: the peer is listening but momentarily saturated, which
|
||||
* is a busy session, not a dead one. Everything else — including a socket
|
||||
* file left behind by a crashed process — is dead, which is the point: a
|
||||
* stale socket inode still stats fine, so only a dial can tell the
|
||||
* difference.
|
||||
* `unknown` is the load-bearing member. A probe that timed out, or that this
|
||||
* process was not permitted to make, has established nothing at all — and a
|
||||
* caller that deletes on the verdict must not spend "could not tell" as proof
|
||||
* of death. Only `dead` is a definitive negative: the dial reached the path
|
||||
* and nothing was there.
|
||||
*/
|
||||
export function probePeerSocket(socketPath: string): Promise<boolean> {
|
||||
export type PeerSocketVerdict = 'alive' | 'dead' | 'unknown';
|
||||
|
||||
/**
|
||||
* Dial `socketPath` and report what that established.
|
||||
*
|
||||
* `alive` means a listener answered, or the dial hit a full listen backlog
|
||||
* (EAGAIN on POSIX, EBUSY on Windows named pipes) — a busy session, not a
|
||||
* dead one. `dead` means the path was reached and nothing held it: no file
|
||||
* (ENOENT), or a socket inode a crashed process left behind (ECONNREFUSED).
|
||||
* A stale inode still stats fine, so only a dial can tell those apart, which
|
||||
* is the point of probing at all.
|
||||
*
|
||||
* Everything else is `unknown`: the 250 ms deadline (a slow peer and a
|
||||
* stalled prober are indistinguishable from here), local descriptor
|
||||
* exhaustion (EMFILE, and ENFILE which a neighbouring process can cause
|
||||
* while this one is healthy), permission errors, and any errno not
|
||||
* enumerated above.
|
||||
*/
|
||||
export function probePeerSocketVerdict(
|
||||
socketPath: string,
|
||||
): Promise<PeerSocketVerdict> {
|
||||
return new Promise((resolve) => {
|
||||
if (!isLocalIpcPath(socketPath)) {
|
||||
resolve(false);
|
||||
resolve('unknown');
|
||||
return;
|
||||
}
|
||||
const socket = net.connect({ path: socketPath });
|
||||
let settled = false;
|
||||
const settle = (alive: boolean) => {
|
||||
const settle = (verdict: PeerSocketVerdict) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeout(deadline);
|
||||
socket.destroy();
|
||||
resolve(alive);
|
||||
resolve(verdict);
|
||||
};
|
||||
// An absolute deadline rather than socket.setTimeout, for the same
|
||||
// reason sendPeerFrame uses one: an idle timer is reset by any byte.
|
||||
const deadline = setTimeout(() => settle(false), PROBE_TIMEOUT_MS);
|
||||
const deadline = setTimeout(() => settle('unknown'), PROBE_TIMEOUT_MS);
|
||||
deadline.unref();
|
||||
socket.on('connect', () => settle(true));
|
||||
socket.on('error', (error: NodeJS.ErrnoException) =>
|
||||
settle(error.code === 'EAGAIN' || error.code === 'EBUSY'),
|
||||
);
|
||||
socket.on('connect', () => settle('alive'));
|
||||
socket.on('error', (error: NodeJS.ErrnoException) => {
|
||||
const code = error.code;
|
||||
if (code === 'EAGAIN' || code === 'EBUSY') {
|
||||
settle('alive');
|
||||
return;
|
||||
}
|
||||
if (code === 'ENOENT' || code === 'ECONNREFUSED') {
|
||||
settle('dead');
|
||||
return;
|
||||
}
|
||||
settle('unknown');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* True when something is listening on `socketPath`.
|
||||
*
|
||||
* Only a definitive `alive` reads as true, so an inconclusive probe is not
|
||||
* reachable — which is what a read-only listing wants. Callers that DELETE
|
||||
* on the answer must use {@link probePeerSocketVerdict} and require `dead`:
|
||||
* this collapse maps `unknown` and `dead` onto the same `false`, and acting
|
||||
* destructively on that would treat "could not tell" as "provably gone".
|
||||
*
|
||||
* Kept for callers outside this package; nothing in this repository uses
|
||||
* it. The read-only listing it was written for moved to the verdict so
|
||||
* its own tests could tell `unknown` from `dead`, and this wrapper's
|
||||
* suite in `uds-client.test.ts` is now what pins the collapse.
|
||||
*/
|
||||
export function probePeerSocket(socketPath: string): Promise<boolean> {
|
||||
return probePeerSocketVerdict(socketPath).then(
|
||||
(verdict) => verdict === 'alive',
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
/**
|
||||
* The bind-time failures no real filesystem can be made to produce: a
|
||||
* socket `chmod` that fails after `listen()` already succeeded, a
|
||||
* directory that disappears between its own `mkdir` and `chmod`, and a
|
||||
* `listen()` that fails twice over.
|
||||
*
|
||||
* Every other failure in `bindAt` has a fixture that produces it -- a
|
||||
* file where a directory belongs, a path over `sun_path`, a live
|
||||
* squatter. These do not. The socket is created by `listen()` a
|
||||
* microsecond before the chmod, inside a directory this process just
|
||||
* chmod'd to 0700 and owns; the window in which a sweeper or a tmpfs
|
||||
* cleaner can remove that directory is two syscalls wide; and a sibling
|
||||
* name is eight random bytes, so nothing can be planted in its way.
|
||||
* Stubbing is the only way in.
|
||||
*
|
||||
* They live in their own file because the stubs have to be module-level,
|
||||
* and routing the real-socket suite in `uds-inbox.test.ts` through a
|
||||
* patched `node:fs/promises` and `node:net` would put mocks under sixty
|
||||
* tests that have no need of them.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import * as net from 'node:net';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
/** A `chmod` to fail: which path suffix, and with which errno. */
|
||||
let chmodFailure: { suffix: string; code: string } | null = null;
|
||||
|
||||
/** A recursive `mkdir` to fail: which path suffix, and with which errno. */
|
||||
let mkdirFailure: { suffix: string; code: string } | null = null;
|
||||
|
||||
/** Set to an errno every `listen()` should fail with; null binds for real. */
|
||||
let failListenWith: string | null = null;
|
||||
|
||||
vi.mock('node:fs/promises', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:fs/promises')>();
|
||||
const mkdir = (async (...args: unknown[]) => {
|
||||
const target = String(args[0]);
|
||||
if (mkdirFailure !== null && target.endsWith(mkdirFailure.suffix)) {
|
||||
throw Object.assign(
|
||||
new Error(`${mkdirFailure.code}: mkdir '${target}'`),
|
||||
{ code: mkdirFailure.code },
|
||||
);
|
||||
}
|
||||
return (actual.mkdir as (...inner: unknown[]) => Promise<unknown>)(...args);
|
||||
}) as typeof actual.mkdir;
|
||||
const chmod: typeof actual.chmod = async (target, mode) => {
|
||||
if (chmodFailure !== null && String(target).endsWith(chmodFailure.suffix)) {
|
||||
throw Object.assign(
|
||||
new Error(`${chmodFailure.code}: chmod '${String(target)}'`),
|
||||
{ code: chmodFailure.code },
|
||||
);
|
||||
}
|
||||
return actual.chmod(target, mode);
|
||||
};
|
||||
return { ...actual, mkdir, chmod, default: { ...actual, mkdir, chmod } };
|
||||
});
|
||||
|
||||
vi.mock('node:net', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('node:net')>();
|
||||
// `createServer` is overloaded, so the wrapper is typed through the
|
||||
// namespace member rather than by restating either signature.
|
||||
type CreateServer = typeof actual.createServer;
|
||||
const createServer = ((...args: unknown[]) => {
|
||||
const server = (actual.createServer as (...a: unknown[]) => net.Server)(
|
||||
...args,
|
||||
);
|
||||
const listen = server.listen.bind(server);
|
||||
server.listen = ((...listenArgs: never[]) => {
|
||||
if (failListenWith === null) return listen(...listenArgs);
|
||||
// Asynchronously, the way a real bind failure arrives: `listenAt`
|
||||
// subscribes and then returns to the event loop.
|
||||
setImmediate(() =>
|
||||
server.emit(
|
||||
'error',
|
||||
Object.assign(new Error(`${failListenWith}: address in use`), {
|
||||
code: failListenWith,
|
||||
}),
|
||||
),
|
||||
);
|
||||
return server;
|
||||
}) as typeof server.listen;
|
||||
return server;
|
||||
}) as CreateServer;
|
||||
return { ...actual, createServer, default: { ...actual, createServer } };
|
||||
});
|
||||
|
||||
const fs = await import('node:fs/promises');
|
||||
const { describePeerInboxFailure, getLastPeerInboxFailure, startPeerInbox } =
|
||||
await import('./uds-inbox.js');
|
||||
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
chmodFailure = null;
|
||||
mkdirFailure = null;
|
||||
failListenWith = null;
|
||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-bindfail-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
chmodFailure = null;
|
||||
mkdirFailure = null;
|
||||
failListenWith = null;
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** A live listener holding `socketPath`, as a peer session would. */
|
||||
async function occupy(socketPath: string): Promise<net.Server> {
|
||||
await fs.mkdir(path.dirname(socketPath), { recursive: true });
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(socketPath, () => resolve());
|
||||
});
|
||||
server.unref();
|
||||
return server;
|
||||
}
|
||||
|
||||
describe.skipIf(isWindows)('a socket that cannot be locked down', () => {
|
||||
it('reports chmod_failed and leaves nothing behind', async () => {
|
||||
// A socket this process cannot restrict to 0600 is worse than no
|
||||
// socket at all -- the permission bits are the whole access-control
|
||||
// story -- so the bind is abandoned rather than completed. Drop the
|
||||
// `chmod_failed` override and `classify` returns `permission` for
|
||||
// EPERM, whose sentence blames the *directory* and sends the user to
|
||||
// fix permissions that are already correct; drop the cleanup and the
|
||||
// bound socket and its directory are orphaned where no sweep derives
|
||||
// a parent from.
|
||||
const socketPath = path.join(tmpDir, 'socks', '4242.sock');
|
||||
chmodFailure = { suffix: '.sock', code: 'EPERM' };
|
||||
|
||||
const started = await startPeerInbox({ socketPath, onFrame: () => {} });
|
||||
|
||||
expect(started).toBeNull();
|
||||
const failure = getLastPeerInboxFailure();
|
||||
expect(failure?.cause).toBe('chmod_failed');
|
||||
expect(failure?.socketPath).toBe(socketPath);
|
||||
await expect(fs.stat(socketPath)).rejects.toThrow();
|
||||
await expect(fs.stat(path.dirname(socketPath))).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('blames the configured path, not the sibling it moved aside to', async () => {
|
||||
// A failure after a sibling advance must still name what the user
|
||||
// configured. The sibling is a name this process minted seconds ago:
|
||||
// putting it in the startup banner and in `/peers` sends the user to
|
||||
// inspect a file that appears nowhere in their settings, while the
|
||||
// path they can actually act on goes unmentioned. Node's errno
|
||||
// message keeps naming the sibling in `detail`, which is where a log
|
||||
// reader wants it.
|
||||
const requested = path.join(tmpDir, 'socks', '4242.sock');
|
||||
const squatter = await occupy(requested);
|
||||
chmodFailure = { suffix: '.sock', code: 'EPERM' };
|
||||
try {
|
||||
const started = await startPeerInbox({
|
||||
socketPath: requested,
|
||||
onFrame: () => {},
|
||||
});
|
||||
|
||||
expect(started).toBeNull();
|
||||
const failure = getLastPeerInboxFailure();
|
||||
expect(failure?.cause).toBe('chmod_failed');
|
||||
// The advance happened -- otherwise this test proves nothing about
|
||||
// it -- and the report still names the requested path.
|
||||
expect(failure?.detail).toMatch(/4242-[0-9a-f]{8}\.sock/);
|
||||
expect(failure?.socketPath).toBe(requested);
|
||||
// And the live session next door is untouched.
|
||||
expect((await fs.lstat(requested)).isSocket()).toBe(true);
|
||||
} finally {
|
||||
squatter.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(isWindows)('a listen that fails at both names', () => {
|
||||
it('blames the configured path, not the sibling it retried at', async () => {
|
||||
// The raced-EADDRINUSE retry: the path was taken between the probe
|
||||
// and the listen, the retry at a sibling fails too, and what is left
|
||||
// to report is a real bind failure. It must be reported against the
|
||||
// path the user configured. Naming the sibling instead puts a
|
||||
// filename this process minted eight random bytes ago into the
|
||||
// startup banner and into `/peers`, and sends the user to inspect a
|
||||
// file that exists in no configuration of theirs. Node's message
|
||||
// keeps naming the attempted path in `detail`.
|
||||
const requested = path.join(tmpDir, 'socks', '4242.sock');
|
||||
failListenWith = 'EADDRINUSE';
|
||||
|
||||
const started = await startPeerInbox({
|
||||
socketPath: requested,
|
||||
onFrame: () => {},
|
||||
});
|
||||
|
||||
expect(started).toBeNull();
|
||||
const failure = getLastPeerInboxFailure();
|
||||
expect(failure?.cause).toBe('bind_failed');
|
||||
expect(failure?.socketPath).toBe(requested);
|
||||
expect(failure?.socketPath).not.toMatch(/-[0-9a-f]{8}\.sock$/);
|
||||
// The directory the failed candidate created is not left behind.
|
||||
await expect(fs.stat(path.dirname(requested))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(isWindows)('a socket directory that vanishes mid-setup', () => {
|
||||
it('reports permission when mkdir returns ENOENT under an existing parent', async () => {
|
||||
const socketPath = path.join(tmpDir, 'socks', '4242.sock');
|
||||
mkdirFailure = { suffix: `${path.sep}socks`, code: 'ENOENT' };
|
||||
|
||||
const started = await startPeerInbox({ socketPath, onFrame: () => {} });
|
||||
|
||||
expect(started).toBeNull();
|
||||
const failure = getLastPeerInboxFailure();
|
||||
expect(failure?.cause).toBe('permission');
|
||||
expect(failure?.socketPath).toBe(socketPath);
|
||||
expect(describePeerInboxFailure(failure!)).toContain(
|
||||
'cannot create or lock down',
|
||||
);
|
||||
});
|
||||
|
||||
it('names the missing ancestor rather than an unknown error', async () => {
|
||||
// `mkdir` is recursive, so it creates the ancestors it needs and
|
||||
// ENOENT never comes from there -- which left `missing_ancestor` with
|
||||
// no producer at all. It is still reachable: `mkdir`, `lstat` and
|
||||
// `chmod` are three syscalls, and a sweeper or a tmpfs cleaner
|
||||
// removing the directory in between makes the next one fail ENOENT.
|
||||
// Stubbing the directory chmod reproduces that window exactly.
|
||||
//
|
||||
// Drop the ENOENT case from `classify` and this renders as `unknown`,
|
||||
// which hands the user a raw errno string instead of naming the
|
||||
// parent that is gone.
|
||||
const socketPath = path.join(tmpDir, 'socks', '4242.sock');
|
||||
chmodFailure = { suffix: `${path.sep}socks`, code: 'ENOENT' };
|
||||
|
||||
const started = await startPeerInbox({ socketPath, onFrame: () => {} });
|
||||
|
||||
expect(started).toBeNull();
|
||||
const failure = getLastPeerInboxFailure();
|
||||
expect(failure?.cause).toBe('missing_ancestor');
|
||||
expect(failure?.socketPath).toBe(socketPath);
|
||||
expect(describePeerInboxFailure(failure!)).toContain('does not exist');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
import { leaveStaleSocket } from '../test-utils/stale-socket.js';
|
||||
|
||||
const { isPidAlive } = vi.hoisted(() => ({
|
||||
isPidAlive: vi.fn<(pid: number) => boolean>(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/process-liveness.js', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('../utils/process-liveness.js')>();
|
||||
return { ...actual, isPidAlive };
|
||||
});
|
||||
|
||||
const { sweepOrphanSocketDirs, sweepOrphanSockets } = await import(
|
||||
'./uds-inbox.js'
|
||||
);
|
||||
|
||||
const DEAD_PID = 2_147_483_647;
|
||||
const isWindows = process.platform === 'win32';
|
||||
let tmpDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
isPidAlive.mockReset();
|
||||
tmpDir = await fs.mkdtemp(
|
||||
isWindows ? path.join(os.tmpdir(), 'qsl-') : '/tmp/qsl-',
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe.skipIf(isWindows)('orphan sweep liveness recheck', () => {
|
||||
it('keeps a socket when its PID revives after the probe', async () => {
|
||||
const dir = path.join(tmpDir, 'qwen-socks');
|
||||
const socketPath = path.join(dir, `${DEAD_PID}.sock`);
|
||||
await fs.mkdir(dir);
|
||||
await leaveStaleSocket(socketPath);
|
||||
isPidAlive.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
||||
|
||||
expect(
|
||||
await sweepOrphanSockets(dir, path.join(dir, `${DEAD_PID - 1}.sock`)),
|
||||
).toBe(0);
|
||||
expect(isPidAlive).toHaveBeenCalledTimes(2);
|
||||
await expect(fs.stat(socketPath)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
it('keeps a fallback directory when its PID revives after the probe', async () => {
|
||||
const dir = path.join(tmpDir, `qwen-socks-${'a'.repeat(16)}`);
|
||||
const socketPath = path.join(dir, `${DEAD_PID}.sock`);
|
||||
await fs.mkdir(dir);
|
||||
await leaveStaleSocket(socketPath);
|
||||
isPidAlive.mockReturnValueOnce(false).mockReturnValueOnce(true);
|
||||
|
||||
expect(
|
||||
await sweepOrphanSocketDirs(
|
||||
tmpDir,
|
||||
path.join(tmpDir, `qwen-socks-${'b'.repeat(16)}`),
|
||||
),
|
||||
).toBe(0);
|
||||
expect(isPidAlive).toHaveBeenCalledTimes(2);
|
||||
await expect(fs.stat(dir)).resolves.toBeDefined();
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large.
Load diff
@@ -26,25 +26,46 @@
|
||||
* content is not from its user.
|
||||
*/
|
||||
|
||||
import { timingSafeEqual } from 'node:crypto';
|
||||
import { randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import * as fsSync from 'node:fs';
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as net from 'node:net';
|
||||
import * as path from 'node:path';
|
||||
import { createDebugLogger } from '../utils/debugLogger.js';
|
||||
import { isPidAlive } from '../utils/process-liveness.js';
|
||||
import {
|
||||
MAX_FRAME_BYTES,
|
||||
parsePeerAuthLine,
|
||||
parsePeerFrame,
|
||||
type PeerFrame,
|
||||
} from './peer-frames.js';
|
||||
import { isLocalIpcPath, resolvePeerSocketPath } from './socket-path.js';
|
||||
import {
|
||||
isLocalIpcPath,
|
||||
MAX_SOCKET_PATH_BYTES,
|
||||
resolvePeerSocketCandidates,
|
||||
SOCKET_DIR_NAME,
|
||||
} from './socket-path.js';
|
||||
import { probePeerSocketVerdict } from './uds-client.js';
|
||||
|
||||
const debugLogger = createDebugLogger('PEER_IPC');
|
||||
|
||||
const SOCKET_DIR_MODE = 0o700;
|
||||
const SOCKET_MODE = 0o600;
|
||||
|
||||
// An empty fallback directory may be between mkdir and listen in another
|
||||
// session. A normal bind takes milliseconds; a minute keeps that window safe
|
||||
// while still letting later sessions collect directories left by a crash.
|
||||
const EMPTY_FALLBACK_DIR_GRACE_MS = 60_000;
|
||||
|
||||
/**
|
||||
* How many sweep probes run at once. Each one holds a file descriptor
|
||||
* until it connects or times out, so this is an fd-pressure knob, not a
|
||||
* correctness bound: every batch runs, however many there are. Exported
|
||||
* so tests can size their fixtures to span more than one batch whatever
|
||||
* this is tuned to.
|
||||
*/
|
||||
export const SWEEP_BATCH_SIZE = 16;
|
||||
|
||||
/**
|
||||
* Most peers connected at once.
|
||||
*
|
||||
@@ -56,17 +77,261 @@ const SOCKET_MODE = 0o600;
|
||||
export const MAX_PEER_CONNECTIONS = 64;
|
||||
|
||||
/**
|
||||
* How long a connection may sit without traffic before it is dropped.
|
||||
* How long a connection may go without completing a line before it is
|
||||
* dropped.
|
||||
*
|
||||
* The 1 MiB cap only fires on a peer that keeps *writing* without a
|
||||
* newline. A peer that connects, writes nothing, and never hangs up costs
|
||||
* nothing to create and would otherwise hold a descriptor for the life of
|
||||
* the session.
|
||||
* Measured from connect to the auth line, and from there to each
|
||||
* successfully parsed frame — never reset by a lone byte, and never by a
|
||||
* line that failed to parse. An idle timer that any byte resets can be
|
||||
* held open forever by a peer dribbling one byte at a time under the
|
||||
* 1 MiB cap; so can a line deadline, by a peer writing one junk line per
|
||||
* deadline. Only progress re-arms this one. A sender writes its frame
|
||||
* and hangs up, so a legitimate connection never comes near it.
|
||||
*/
|
||||
export const CONNECTION_IDLE_TIMEOUT_MS = 30_000;
|
||||
export const LINE_DEADLINE_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Why the inbox could not bind, in terms a user can act on.
|
||||
*
|
||||
* - `non_local`: the configured path is not an absolute local path.
|
||||
* - `unsupported_platform`: automatic inbox paths are unavailable here.
|
||||
* - `not_directory`: something that is not a directory (a file, a
|
||||
* symlink) sits where the socket directory should be.
|
||||
* - `foreign_owner`: the socket directory belongs to another uid.
|
||||
* - `permission`: this user cannot create, enter or lock down the socket
|
||||
* directory.
|
||||
* - `missing_ancestor`: a parent of the socket directory does not exist.
|
||||
* - `path_too_long`: the path exceeds what `sun_path` can hold.
|
||||
* - `sibling_too_long`: the path is in use or could not be verified free,
|
||||
* and the `<pid>-<8hex>.sock` alternative does not fit `sun_path`. The
|
||||
* requested path itself is within the limit.
|
||||
* - `bind_failed`: `listen()` failed for another reason (the errno is in
|
||||
* `detail`).
|
||||
* - `chmod_failed`: the socket could not be restricted to 0600.
|
||||
* - `unknown`: anything else; `detail` carries the error.
|
||||
*/
|
||||
export type PeerInboxFailureCause =
|
||||
| 'non_local'
|
||||
| 'unsupported_platform'
|
||||
| 'not_directory'
|
||||
| 'foreign_owner'
|
||||
| 'permission'
|
||||
| 'missing_ancestor'
|
||||
| 'path_too_long'
|
||||
| 'sibling_too_long'
|
||||
| 'bind_failed'
|
||||
| 'chmod_failed'
|
||||
| 'unknown';
|
||||
|
||||
export interface PeerInboxStartFailure {
|
||||
cause: PeerInboxFailureCause;
|
||||
/**
|
||||
* The first candidate tried: exactly the requested path when one is
|
||||
* explicit; otherwise derived from `XDG_RUNTIME_DIR` when set, or from a
|
||||
* tmpdir nonce this process minted. Later fallbacks and sibling names are
|
||||
* never reported. The name actually attempted stays in `detail`, which
|
||||
* carries Node's errno message verbatim.
|
||||
*/
|
||||
socketPath: string;
|
||||
/** The underlying error, for logs. */
|
||||
detail: string;
|
||||
/** What the user can do about it. */
|
||||
hint: string;
|
||||
/** How many candidate paths were tried before giving up. */
|
||||
attempts: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* The failure that turned messaging off for this session, if any.
|
||||
*
|
||||
* A session that cannot bind its inbox is not broken — it carries on —
|
||||
* but it is unreachable, and the only symptom of that is peers reporting
|
||||
* it absent. So the failure is kept where the UI can show it at startup
|
||||
* and where `/peers` can repeat it, instead of living only in a debug
|
||||
* log nobody has on. Cleared by a successful bind.
|
||||
*/
|
||||
let lastStartFailure: PeerInboxStartFailure | null = null;
|
||||
|
||||
export function getLastPeerInboxFailure(): PeerInboxStartFailure | null {
|
||||
return lastStartFailure;
|
||||
}
|
||||
|
||||
/** One line for a human: what failed, where, and what to do. */
|
||||
export function describePeerInboxFailure(
|
||||
failure: PeerInboxStartFailure,
|
||||
): string {
|
||||
const where = path.dirname(failure.socketPath);
|
||||
const attempts =
|
||||
failure.attempts > 1 ? ` Tried ${failure.attempts} candidate paths.` : '';
|
||||
switch (failure.cause) {
|
||||
case 'non_local':
|
||||
return `the socket path "${failure.socketPath}" is not an absolute local path. ${failure.hint}${attempts}`;
|
||||
case 'unsupported_platform':
|
||||
return `cross-session messaging is not available on this platform. ${failure.hint}${attempts}`;
|
||||
case 'not_directory':
|
||||
return `"${where}" could not be created or is not a plain directory (${failure.detail}). ${failure.hint}${attempts}`;
|
||||
case 'foreign_owner':
|
||||
return `"${where}" belongs to another user. ${failure.hint}${attempts}`;
|
||||
case 'permission':
|
||||
return `this user cannot create or lock down "${where}" (${failure.detail}). ${failure.hint}${attempts}`;
|
||||
case 'missing_ancestor':
|
||||
return `a parent of "${where}" does not exist. ${failure.hint}${attempts}`;
|
||||
case 'path_too_long':
|
||||
return `"${failure.socketPath}" is longer than the ${MAX_SOCKET_PATH_BYTES}-byte socket path limit. ${failure.hint}${attempts}`;
|
||||
case 'sibling_too_long':
|
||||
// Deliberately not the path_too_long sentence: this path fits, and
|
||||
// telling the user it is over a limit they can measure it against
|
||||
// is a claim they can disprove. What does not fit is the name we
|
||||
// would move aside to.
|
||||
return `the socket path "${failure.socketPath}" is in use or could not be verified free, and the alternative name needed to work around it would exceed the ${MAX_SOCKET_PATH_BYTES}-byte socket path limit. ${failure.hint}${attempts}`;
|
||||
case 'bind_failed':
|
||||
return `the socket could not be bound at "${failure.socketPath}" (${failure.detail}). ${failure.hint}${attempts}`;
|
||||
case 'chmod_failed':
|
||||
return `the socket at "${failure.socketPath}" could not be restricted to this user (${failure.detail}). ${failure.hint}${attempts}`;
|
||||
default:
|
||||
return `${failure.detail} (at "${failure.socketPath}"). ${failure.hint}${attempts}`;
|
||||
}
|
||||
}
|
||||
|
||||
const HINT_RUNTIME_DIR =
|
||||
'Set XDG_RUNTIME_DIR (or TMPDIR) to a directory you own, then restart.';
|
||||
|
||||
class InboxSetupError extends Error {
|
||||
constructor(
|
||||
readonly failureCause: PeerInboxFailureCause,
|
||||
message: string,
|
||||
readonly hint: string = HINT_RUNTIME_DIR,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'InboxSetupError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Map an errno from mkdir/lstat/chmod/listen onto a cause and a hint. */
|
||||
function classify(error: unknown, socketPath: string): PeerInboxStartFailure {
|
||||
if (error instanceof InboxSetupError) {
|
||||
return {
|
||||
cause: error.failureCause,
|
||||
socketPath,
|
||||
detail: error.message,
|
||||
hint: error.hint,
|
||||
attempts: 1,
|
||||
};
|
||||
}
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
const detail = describe(error);
|
||||
const base = { socketPath, detail, attempts: 1 };
|
||||
switch (code) {
|
||||
case 'EACCES':
|
||||
case 'EPERM':
|
||||
case 'EROFS':
|
||||
return { ...base, cause: 'permission', hint: HINT_RUNTIME_DIR };
|
||||
case 'ENOENT':
|
||||
return { ...base, cause: 'missing_ancestor', hint: HINT_RUNTIME_DIR };
|
||||
// mkdir(recursive) reports a file in the way as EEXIST, not ENOTDIR.
|
||||
case 'EEXIST':
|
||||
case 'ENOTDIR':
|
||||
case 'ELOOP':
|
||||
return {
|
||||
...base,
|
||||
cause: 'not_directory',
|
||||
hint: 'Remove it, or set XDG_RUNTIME_DIR to a directory you own, then restart.',
|
||||
};
|
||||
case 'ENAMETOOLONG':
|
||||
return {
|
||||
...base,
|
||||
cause: 'path_too_long',
|
||||
hint: 'Set XDG_RUNTIME_DIR or TMPDIR to a shorter directory, then restart.',
|
||||
};
|
||||
default:
|
||||
return { ...base, cause: 'unknown', hint: HINT_RUNTIME_DIR };
|
||||
}
|
||||
}
|
||||
|
||||
async function classifyMkdirFailure(
|
||||
error: unknown,
|
||||
socketPath: string,
|
||||
socketDir: string,
|
||||
): Promise<PeerInboxStartFailure> {
|
||||
if ((error as NodeJS.ErrnoException)?.code === 'ENOENT') {
|
||||
try {
|
||||
await fs.lstat(path.dirname(socketDir));
|
||||
return classify(
|
||||
new InboxSetupError('permission', describe(error)),
|
||||
socketPath,
|
||||
);
|
||||
} catch {
|
||||
// The parent really is absent, so the original ENOENT is accurate.
|
||||
}
|
||||
}
|
||||
return classify(error, socketPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Only `<digits>.sock`, or the `<digits>-<8 hex>.sock` sibling a PID
|
||||
* collision forces, is a socket this code created.
|
||||
*
|
||||
* Same strictness as the session registry's filename guard, for the same
|
||||
* reason: a lenient match would let the sweep delete a file it never
|
||||
* wrote. The optional suffix is matched exactly so the sweep reaps a
|
||||
* sibling left by a crash the same way it reaps a plain one.
|
||||
*/
|
||||
const SOCKET_FILENAME = /^\d+(-[0-9a-f]{8})?\.sock$/;
|
||||
|
||||
/** The PID a socket filename is keyed by, or null if it is not ours. */
|
||||
function pidOfSocketFilename(name: string): number | null {
|
||||
if (!SOCKET_FILENAME.test(name)) return null;
|
||||
const pid = Number.parseInt(name.split(/[-.]/)[0]!, 10);
|
||||
return Number.isInteger(pid) && pid > 0 ? pid : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one classification for "the path is in use or could not be verified
|
||||
* free, and the sibling name does not fit `sun_path`".
|
||||
*
|
||||
* Both producers -- the pre-bind probe and the raced `EADDRINUSE` retry --
|
||||
* go through here, so the two orderings cannot report the same condition
|
||||
* differently.
|
||||
*/
|
||||
function siblingOverflowFailure(socketPath: string): PeerInboxStartFailure {
|
||||
return classify(
|
||||
new InboxSetupError(
|
||||
'sibling_too_long',
|
||||
`${socketPath} is in use or could not be verified free and a sibling name would exceed sun_path`,
|
||||
'Set XDG_RUNTIME_DIR or TMPDIR to a shorter directory, then restart.',
|
||||
),
|
||||
socketPath,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A sibling path for `socketPath`, keyed by the same PID but distinct.
|
||||
*
|
||||
* Used when the PID-keyed path is in use or cannot be verified free. A
|
||||
* live listener can arise when two PID namespaces share a runtime
|
||||
* directory and both sessions get the same PID. Returns null when the
|
||||
* sibling would not fit in `sun_path`, so the caller falls through to the
|
||||
* next candidate rather than binding something that cannot work.
|
||||
*/
|
||||
function siblingSocketPath(socketPath: string): string | null {
|
||||
const dir = path.dirname(socketPath);
|
||||
const base = path.basename(socketPath, '.sock').split('-')[0]!;
|
||||
const sibling = path.join(
|
||||
dir,
|
||||
`${base}-${randomBytes(4).toString('hex')}.sock`,
|
||||
);
|
||||
return Buffer.byteLength(sibling) <= MAX_SOCKET_PATH_BYTES ? sibling : null;
|
||||
}
|
||||
|
||||
/** The fallback directories `resolvePeerSocketCandidates` mints. */
|
||||
const NONCE_DIRNAME = new RegExp(`^${SOCKET_DIR_NAME}-[0-9a-f]{16}$`);
|
||||
|
||||
export interface PeerInboxOptions {
|
||||
/** Defaults to this process's resolved socket path. */
|
||||
/**
|
||||
* Bind exactly here instead of trying this process's candidate paths
|
||||
* in order. Tests use it; production leaves it unset so an unusable
|
||||
* runtime directory falls back instead of turning messaging off.
|
||||
*/
|
||||
socketPath?: string;
|
||||
/**
|
||||
* When set, a connection's first line must be an auth line presenting
|
||||
@@ -88,6 +353,8 @@ export interface PeerInboxOptions {
|
||||
* none.
|
||||
*/
|
||||
onFrame: (frame: PeerFrame, auth?: PeerConnectionAuth) => void;
|
||||
/** Override for tests; production uses {@link LINE_DEADLINE_MS}. */
|
||||
lineDeadlineMs?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -133,53 +400,339 @@ function createLineReader(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete `<pid>.sock` files in `dir` left behind by sessions that are gone.
|
||||
*
|
||||
* A session that exits cleanly unlinks its own socket; one that is killed
|
||||
* cannot, and nothing else removes them, so without this the shared
|
||||
* runtime directory grows by one dead file per crash. Sweeping happens at
|
||||
* bind time rather than on a timer: the directory only accumulates when a
|
||||
* session dies, and a new session starting is the natural moment to
|
||||
* notice.
|
||||
*
|
||||
* Conservative by construction: a file goes only when its PID is
|
||||
* *provably* dead. A live PID is left alone even though it may have been
|
||||
* recycled onto some unrelated process — a leftover file costs a few
|
||||
* bytes, and deleting a live session's socket would make it silently
|
||||
* unreachable.
|
||||
*
|
||||
* "Provably" is meant literally, because `isPidAlive` proves nothing about
|
||||
* a PID from another namespace: the probe must come back `dead`, not merely
|
||||
* "not alive", and liveness is re-read on the turn of the unlink so the
|
||||
* verdict and the deletion cannot straddle a probe's round trip.
|
||||
*/
|
||||
export async function sweepOrphanSockets(
|
||||
dir: string,
|
||||
selfSocketPath: string,
|
||||
): Promise<number> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(dir);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
let swept = 0;
|
||||
for (let offset = 0; offset < entries.length; offset += SWEEP_BATCH_SIZE) {
|
||||
await Promise.all(
|
||||
entries.slice(offset, offset + SWEEP_BATCH_SIZE).map(async (name) => {
|
||||
const pid = pidOfSocketFilename(name);
|
||||
if (pid === null) return;
|
||||
const fullPath = path.join(dir, name);
|
||||
if (fullPath === selfSocketPath) return;
|
||||
if (isPidAlive(pid)) return;
|
||||
// Only a definitive `dead` licenses the unlink. A timed-out or
|
||||
// unpermitted probe establishes nothing, and this path deletes a
|
||||
// file another namespace's live session may be listening on --
|
||||
// where `isPidAlive` is meaningless, the probe is the only guard.
|
||||
if ((await probePeerSocketVerdict(fullPath)) !== 'dead') return;
|
||||
// The probe yielded a full round trip, so re-read liveness on the
|
||||
// same turn as the delete: the verdict above is about the state
|
||||
// before that await, not the state now.
|
||||
if (isPidAlive(pid)) return;
|
||||
try {
|
||||
await fs.unlink(fullPath);
|
||||
swept += 1;
|
||||
} catch {
|
||||
// Raced with another session's sweep, or not ours to remove.
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (swept > 0) {
|
||||
debugLogger.debug(`swept ${swept} orphaned peer socket(s) from ${dir}`);
|
||||
}
|
||||
return swept;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove whole fallback directories (`qwen-socks-<nonce>/`) in `parent`
|
||||
* whose every socket belongs to a dead process.
|
||||
*
|
||||
* Each session that falls back to a shared temp directory mints its own
|
||||
* nonce-named directory, so a killed session leaves a directory behind,
|
||||
* not just a file. Only directories this uid owns, matching the exact
|
||||
* name shape, and holding nothing but provably dead sockets are removed;
|
||||
* anything else is not ours to reason about.
|
||||
*/
|
||||
export async function sweepOrphanSocketDirs(
|
||||
parent: string,
|
||||
selfDir: string,
|
||||
): Promise<number> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = await fs.readdir(parent);
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
||||
let swept = 0;
|
||||
for (let offset = 0; offset < entries.length; offset += SWEEP_BATCH_SIZE) {
|
||||
await Promise.all(
|
||||
entries.slice(offset, offset + SWEEP_BATCH_SIZE).map(async (name) => {
|
||||
if (!NONCE_DIRNAME.test(name)) return;
|
||||
const dir = path.join(parent, name);
|
||||
if (dir === selfDir) return;
|
||||
try {
|
||||
const stat = await fs.lstat(dir);
|
||||
if (!stat.isDirectory()) return;
|
||||
if (uid !== null && stat.uid !== uid) return;
|
||||
const files = await fs.readdir(dir);
|
||||
if (
|
||||
files.length === 0 &&
|
||||
Date.now() - stat.mtimeMs < EMPTY_FALLBACK_DIR_GRACE_MS
|
||||
) {
|
||||
return;
|
||||
}
|
||||
for (const file of files) {
|
||||
const pid = pidOfSocketFilename(file);
|
||||
if (pid === null || isPidAlive(pid)) return;
|
||||
// Same rule as the file sweep: anything short of a definitive
|
||||
// `dead` leaves the whole directory alone.
|
||||
if ((await probePeerSocketVerdict(path.join(dir, file))) !== 'dead')
|
||||
return;
|
||||
}
|
||||
// Re-read every PID on the turn of the delete, after the probes
|
||||
// above have yielded; one revived socket vetoes the directory.
|
||||
for (const file of files) {
|
||||
const pid = pidOfSocketFilename(file);
|
||||
if (pid === null || isPidAlive(pid)) return;
|
||||
}
|
||||
for (const file of files) await fs.unlink(path.join(dir, file));
|
||||
await fs.rmdir(dir);
|
||||
swept += 1;
|
||||
} catch {
|
||||
// Raced, or not ours.
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (swept > 0) {
|
||||
debugLogger.debug(
|
||||
`swept ${swept} orphaned peer socket director${swept === 1 ? 'y' : 'ies'} from ${parent}`,
|
||||
);
|
||||
}
|
||||
return swept;
|
||||
}
|
||||
|
||||
function sweepAround(socketPath: string): Promise<number> {
|
||||
const dir = path.dirname(socketPath);
|
||||
const base = path.basename(dir);
|
||||
if (base === SOCKET_DIR_NAME) return sweepOrphanSockets(dir, socketPath);
|
||||
if (NONCE_DIRNAME.test(base)) {
|
||||
return sweepOrphanSocketDirs(path.dirname(dir), dir);
|
||||
}
|
||||
return Promise.resolve(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Bind this session's inbox.
|
||||
*
|
||||
* Returns null instead of throwing when the socket cannot be bound: a
|
||||
* session that cannot be messaged is a degraded session, not a broken
|
||||
* one, and the caller has nothing useful to do with the failure beyond
|
||||
* carrying on.
|
||||
* Tries each candidate path in order (see `resolvePeerSocketCandidates`)
|
||||
* and returns null only when every one failed. The failure is then kept
|
||||
* for {@link getLastPeerInboxFailure}: a session that cannot be messaged
|
||||
* is a degraded session, not a broken one, but its user has to be told
|
||||
* why or they will never know it is unreachable.
|
||||
*/
|
||||
export async function startPeerInbox(
|
||||
options: PeerInboxOptions,
|
||||
): Promise<PeerInbox | null> {
|
||||
const socketPath = options.socketPath ?? resolvePeerSocketPath();
|
||||
const candidates =
|
||||
options.socketPath !== undefined
|
||||
? [options.socketPath]
|
||||
: resolvePeerSocketCandidates();
|
||||
|
||||
if (!isLocalIpcPath(socketPath)) {
|
||||
debugLogger.error(`refusing to bind a non-local IPC path: ${socketPath}`);
|
||||
return null;
|
||||
let failure: PeerInboxStartFailure | null = null;
|
||||
for (const [index, candidate] of candidates.entries()) {
|
||||
const result = await bindAt(
|
||||
candidate,
|
||||
options,
|
||||
options.socketPath === undefined,
|
||||
);
|
||||
if ('inbox' in result) {
|
||||
lastStartFailure = null;
|
||||
if (index > 0) {
|
||||
debugLogger.warn(
|
||||
`peer inbox bound at fallback path ${candidate} after ${index} unusable candidate(s)`,
|
||||
);
|
||||
}
|
||||
// Fire-and-forget: a sweep is housekeeping, and nothing about this
|
||||
// session's own inbox depends on it. Every candidate matters because
|
||||
// a successful earlier path would otherwise leave stale fallback
|
||||
// directories untouched.
|
||||
for (const sweepCandidate of candidates) {
|
||||
void sweepAround(sweepCandidate).catch(() => {});
|
||||
}
|
||||
return result.inbox;
|
||||
}
|
||||
const attempt = { ...result.failure, attempts: index + 1 };
|
||||
debugLogger.warn(
|
||||
`peer inbox could not bind at ${candidate} (${attempt.cause}): ${attempt.detail}`,
|
||||
);
|
||||
// A machine-level refusal is decided before any filesystem call and
|
||||
// holds for every candidate alike. Counting the rest would tell a
|
||||
// user for whom no path can ever work to go looking for a better
|
||||
// one, and would make the count vary with an unrelated environment
|
||||
// variable.
|
||||
if (attempt.cause === 'unsupported_platform') {
|
||||
failure = { ...attempt, attempts: 1 };
|
||||
break;
|
||||
}
|
||||
// Report the FIRST candidate's diagnosis, not the last. It is either
|
||||
// the explicit requested path or the preferred environment-derived
|
||||
// path; every later candidate is a fallback this process selected or
|
||||
// minted, which appears nowhere in configuration and which the user
|
||||
// cannot act on. `attempts` keeps counting all of them, so the "Tried N
|
||||
// candidate paths." sentence stays true.
|
||||
if (failure === null) {
|
||||
failure = attempt;
|
||||
} else {
|
||||
failure.attempts = index + 1;
|
||||
}
|
||||
// A non-local or over-long path is a property of that candidate, not
|
||||
// of the machine; the next candidate is worth trying. A bind that
|
||||
// failed for another reason usually is too. Only an explicit path
|
||||
// has no next.
|
||||
}
|
||||
|
||||
if (failure) {
|
||||
lastStartFailure = failure;
|
||||
debugLogger.error(
|
||||
`cross-session messaging is OFF for this session: ${describePeerInboxFailure(failure)}`,
|
||||
);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function bindAt(
|
||||
requestedPath: string,
|
||||
options: PeerInboxOptions,
|
||||
automaticPath: boolean,
|
||||
): Promise<{ inbox: PeerInbox } | { failure: PeerInboxStartFailure }> {
|
||||
// Moves to a sibling name when the PID-keyed path is not definitively
|
||||
// dead. `socketPath` is what gets bound and what a successful inbox
|
||||
// reports; `requestedPath` is what a *failure* reports, because the
|
||||
// sibling is a name this process minted (see
|
||||
// `PeerInboxStartFailure.socketPath`).
|
||||
let socketPath = requestedPath;
|
||||
if (!isLocalIpcPath(socketPath)) {
|
||||
const unsupportedPlatform = automaticPath && process.platform === 'win32';
|
||||
return {
|
||||
failure: classify(
|
||||
new InboxSetupError(
|
||||
unsupportedPlatform ? 'unsupported_platform' : 'non_local',
|
||||
unsupportedPlatform
|
||||
? 'automatic peer inbox paths are not supported on Windows'
|
||||
: `refusing to bind a non-local IPC path: ${socketPath}`,
|
||||
unsupportedPlatform
|
||||
? 'Disable cross-session messaging for this session.'
|
||||
: 'Use an absolute local path.',
|
||||
),
|
||||
requestedPath,
|
||||
),
|
||||
};
|
||||
}
|
||||
if (Buffer.byteLength(socketPath) > MAX_SOCKET_PATH_BYTES) {
|
||||
return {
|
||||
failure: classify(
|
||||
Object.assign(new Error(`${socketPath} exceeds sun_path`), {
|
||||
code: 'ENAMETOOLONG',
|
||||
}),
|
||||
requestedPath,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const socketDir = path.dirname(socketPath);
|
||||
// If every candidate fails after creating its directory, the success-path
|
||||
// sweep never runs. Remove it immediately when empty; a directory holding
|
||||
// someone's live socket is never touched, and losing the race to another
|
||||
// session's sweep is not an error worth reporting.
|
||||
const dropDirIfEmpty = async () => {
|
||||
await fs.rmdir(socketDir).catch(() => {});
|
||||
};
|
||||
const dir = socketDir;
|
||||
try {
|
||||
const dir = path.dirname(socketPath);
|
||||
await fs.mkdir(dir, { recursive: true, mode: SOCKET_DIR_MODE });
|
||||
} catch (error) {
|
||||
await dropDirIfEmpty();
|
||||
return {
|
||||
failure: await classifyMkdirFailure(error, requestedPath, socketDir),
|
||||
};
|
||||
}
|
||||
try {
|
||||
// Both mkdir(recursive) and chmod succeed straight through a symlink,
|
||||
// and the `/tmp` fallback directory sits in a world-writable place
|
||||
// where another user can create it first. If they point it at a
|
||||
// directory of ours, the chmod below silently retargets that
|
||||
// directory and the socket lands inside it. Insist on a real
|
||||
// directory we own; anything else means someone got there first.
|
||||
// and a shared temp directory is a place where another user can
|
||||
// create our directory first. If they point it at a directory of
|
||||
// ours, the chmod below silently retargets that directory and the
|
||||
// socket lands inside it. Insist on a real directory we own; anything
|
||||
// else means someone got there first.
|
||||
const dirStat = await fs.lstat(dir);
|
||||
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
|
||||
if (!dirStat.isDirectory()) {
|
||||
throw new Error(`${dir} is not a directory`);
|
||||
throw new InboxSetupError(
|
||||
'not_directory',
|
||||
`${dir} is not a directory`,
|
||||
'Remove it, or set XDG_RUNTIME_DIR to a directory you own, then restart.',
|
||||
);
|
||||
}
|
||||
if (uid !== null && dirStat.uid !== uid) {
|
||||
throw new Error(`${dir} belongs to uid ${dirStat.uid}, not ${uid}`);
|
||||
throw new InboxSetupError(
|
||||
'foreign_owner',
|
||||
`${dir} belongs to uid ${dirStat.uid}, not ${uid}`,
|
||||
);
|
||||
}
|
||||
// mkdir's mode is masked by the umask and ignored outright when the
|
||||
// directory already exists, so chmod is what actually enforces 0700.
|
||||
await fs.chmod(dir, SOCKET_DIR_MODE);
|
||||
} catch (error) {
|
||||
debugLogger.error(`peer inbox directory unusable: ${describe(error)}`);
|
||||
return null;
|
||||
await dropDirIfEmpty();
|
||||
return { failure: classify(error, requestedPath) };
|
||||
}
|
||||
|
||||
// A socket file left behind by a crashed session would make bind() fail
|
||||
// with EADDRINUSE forever. Unlinking is safe because the path is keyed
|
||||
// by our own PID: if a live process were listening there, it would be
|
||||
// this one.
|
||||
// with EADDRINUSE forever, so the path has to be cleared first — but
|
||||
// only when nothing is listening on it. The path is keyed by PID, and a
|
||||
// PID is unique only within its namespace: where two namespaces share a
|
||||
// runtime directory (a container bind-mounting the host's is the usual
|
||||
// way), two live sessions can resolve the same path. Unlinking there
|
||||
// would leave the other session listening on an inode no peer can reach
|
||||
// — silently unreachable, which is the failure this whole path exists
|
||||
// to prevent. So a live or inconclusive socket is left alone and we take
|
||||
// a sibling name instead; only a definitively dead one is removed.
|
||||
// Not `=== 'alive'`: an inconclusive probe must take the sibling name
|
||||
// too. Unlinking on "could not tell" is the same mistake the sweep
|
||||
// guards against, and the sibling costs only a filename.
|
||||
if ((await probePeerSocketVerdict(socketPath)) !== 'dead') {
|
||||
const sibling = siblingSocketPath(socketPath);
|
||||
if (sibling === null) {
|
||||
await dropDirIfEmpty();
|
||||
return { failure: siblingOverflowFailure(requestedPath) };
|
||||
}
|
||||
debugLogger.debug(
|
||||
`${socketPath} is in use or could not be verified free; binding at ${sibling}`,
|
||||
);
|
||||
socketPath = sibling;
|
||||
}
|
||||
try {
|
||||
await fs.unlink(socketPath);
|
||||
} catch {
|
||||
@@ -187,6 +740,7 @@ export async function startPeerInbox(
|
||||
}
|
||||
|
||||
const connections = new Set<net.Socket>();
|
||||
const lineDeadlineMs = options.lineDeadlineMs ?? LINE_DEADLINE_MS;
|
||||
|
||||
const server = net.createServer({ allowHalfOpen: true }, (socket) => {
|
||||
connections.add(socket);
|
||||
@@ -195,10 +749,23 @@ export async function startPeerInbox(
|
||||
// without this one idle peer would pin the process open — exactly what
|
||||
// the server.unref() below is meant to prevent.
|
||||
socket.unref();
|
||||
socket.setTimeout(CONNECTION_IDLE_TIMEOUT_MS, () => {
|
||||
debugLogger.debug('dropping an idle peer connection');
|
||||
socket.destroy();
|
||||
});
|
||||
|
||||
// A deadline, not an idle timer: it is satisfied only by a complete
|
||||
// line, and re-armed from that line, so a peer that keeps the
|
||||
// connection alive with lone bytes still loses it on time.
|
||||
let deadline: NodeJS.Timeout | null = null;
|
||||
const arm = () => {
|
||||
if (deadline) clearTimeout(deadline);
|
||||
deadline = setTimeout(() => {
|
||||
deadline = null;
|
||||
debugLogger.debug(
|
||||
`closing a peer connection that sent no complete line within ${lineDeadlineMs} ms`,
|
||||
);
|
||||
socket.destroy();
|
||||
}, lineDeadlineMs);
|
||||
deadline.unref();
|
||||
};
|
||||
arm();
|
||||
|
||||
let authed = options.requiredToken === undefined;
|
||||
let auth: PeerConnectionAuth | undefined;
|
||||
@@ -210,6 +777,10 @@ export async function startPeerInbox(
|
||||
(line) => {
|
||||
if (refused) return;
|
||||
if (!authed) {
|
||||
// The auth line re-arms: presenting credentials is progress,
|
||||
// and the sender still has its frame to write. Nothing else
|
||||
// re-arms before a frame parses — see below.
|
||||
arm();
|
||||
const presented = parsePeerAuthLine(line);
|
||||
if (presented !== null) {
|
||||
auth = authKindOf(options, presented);
|
||||
@@ -230,8 +801,14 @@ export async function startPeerInbox(
|
||||
debugLogger.debug(
|
||||
`dropping unparseable frame: ${line.slice(0, 200)}`,
|
||||
);
|
||||
// Deliberately no arm(): a peer that keeps writing junk lines
|
||||
// would otherwise hold this connection, and one of the 64
|
||||
// maxConnections slots, for the whole session at two bytes per
|
||||
// deadline. Only progress re-arms, and an unparseable line is
|
||||
// not progress.
|
||||
return;
|
||||
}
|
||||
arm();
|
||||
try {
|
||||
options.onFrame(frame, auth);
|
||||
} catch (error) {
|
||||
@@ -257,6 +834,8 @@ export async function startPeerInbox(
|
||||
debugLogger.debug(`peer connection error: ${error.message}`);
|
||||
});
|
||||
socket.on('close', () => {
|
||||
if (deadline) clearTimeout(deadline);
|
||||
deadline = null;
|
||||
connections.delete(socket);
|
||||
});
|
||||
});
|
||||
@@ -267,26 +846,63 @@ export async function startPeerInbox(
|
||||
debugLogger.error(`peer inbox server error: ${describe(error)}`);
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const listenAt = (target: string) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(socketPath, () => {
|
||||
server.listen(target, () => {
|
||||
server.removeListener('error', reject);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
try {
|
||||
await listenAt(socketPath);
|
||||
} catch (error) {
|
||||
const code = (error as NodeJS.ErrnoException)?.code;
|
||||
if (code === 'ENAMETOOLONG') {
|
||||
debugLogger.error(
|
||||
`peer inbox socket path is too long to bind (${Buffer.byteLength(
|
||||
socketPath,
|
||||
)} bytes): ${socketPath}. Set XDG_RUNTIME_DIR or TMPDIR to a shorter directory.`,
|
||||
);
|
||||
} else {
|
||||
debugLogger.error(`peer inbox failed to bind: ${describe(error)}`);
|
||||
// EADDRINUSE here means the path was taken between the probe above
|
||||
// and this listen — the same cross-namespace PID collision, just
|
||||
// raced. One retry at a sibling name settles it; a second failure is
|
||||
// a real bind failure and is reported as one.
|
||||
const sibling =
|
||||
(error as NodeJS.ErrnoException)?.code === 'EADDRINUSE'
|
||||
? siblingSocketPath(socketPath)
|
||||
: null;
|
||||
if (
|
||||
(error as NodeJS.ErrnoException)?.code === 'EADDRINUSE' &&
|
||||
sibling === null
|
||||
) {
|
||||
// Identical condition to the probe branch above, reached by the
|
||||
// raced ordering instead. Without this it fell through `classify`
|
||||
// -- which has no EADDRINUSE case -- to `bind_failed`, telling the
|
||||
// user to wait for a long-lived twin that will never exit and to
|
||||
// take ownership of a directory that is already theirs, when the
|
||||
// real blocker is a name length.
|
||||
await dropDirIfEmpty();
|
||||
return { failure: siblingOverflowFailure(requestedPath) };
|
||||
}
|
||||
let raced = error;
|
||||
if (sibling !== null) {
|
||||
try {
|
||||
await listenAt(sibling);
|
||||
socketPath = sibling;
|
||||
raced = null;
|
||||
} catch (retryError) {
|
||||
raced = retryError;
|
||||
}
|
||||
}
|
||||
if (raced !== null) {
|
||||
await dropDirIfEmpty();
|
||||
const classified = classify(raced, requestedPath);
|
||||
return {
|
||||
failure:
|
||||
classified.cause === 'unknown'
|
||||
? {
|
||||
...classified,
|
||||
cause: 'bind_failed',
|
||||
hint: 'If another process holds the path, restart after it exits; otherwise set XDG_RUNTIME_DIR to a directory you own.',
|
||||
}
|
||||
: classified,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -294,16 +910,19 @@ export async function startPeerInbox(
|
||||
} catch (error) {
|
||||
// A socket we cannot lock down is worse than no socket at all: the
|
||||
// permission bits are the entire access-control story here.
|
||||
debugLogger.error(
|
||||
`peer inbox socket could not be restricted to 0600, refusing to listen: ${describe(error)}`,
|
||||
);
|
||||
server.close();
|
||||
try {
|
||||
fsSync.unlinkSync(socketPath);
|
||||
} catch {
|
||||
// Best effort.
|
||||
}
|
||||
return null;
|
||||
await dropDirIfEmpty();
|
||||
return {
|
||||
failure: {
|
||||
...classify(error, requestedPath),
|
||||
cause: 'chmod_failed',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Never hold the event loop open on the inbox alone — a session waiting
|
||||
@@ -316,19 +935,21 @@ export async function startPeerInbox(
|
||||
|
||||
let closed = false;
|
||||
return {
|
||||
socketPath,
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const socket of connections) socket.destroy();
|
||||
connections.clear();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
try {
|
||||
await fs.unlink(socketPath);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
debugLogger.debug(`peer inbox closed: ${socketPath}`);
|
||||
inbox: {
|
||||
socketPath,
|
||||
async close() {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
for (const socket of connections) socket.destroy();
|
||||
connections.clear();
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
try {
|
||||
await fs.unlink(socketPath);
|
||||
} catch {
|
||||
// Already gone.
|
||||
}
|
||||
debugLogger.debug(`peer inbox closed: ${socketPath}`);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs/promises';
|
||||
import * as net from 'node:net';
|
||||
import * as path from 'node:path';
|
||||
|
||||
let staleSocketSequence = 0;
|
||||
|
||||
export async function leaveStaleSocket(socketPath: string): Promise<void> {
|
||||
const livePath = path.join(
|
||||
path.dirname(socketPath),
|
||||
`.stale-${staleSocketSequence++}`,
|
||||
);
|
||||
const server = net.createServer();
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(livePath, resolve);
|
||||
});
|
||||
try {
|
||||
await fs.rename(livePath, socketPath);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user