Skip to content

0.14.3: transport and node lifecycle - #58

Open
sym-bot wants to merge 14 commits into
mainfrom
fix/0.14.3-transport
Open

sym-bot wants to merge 14 commits into
mainfrom
fix/0.14.3-transport

Conversation

@sym-bot

@sym-bot sym-bot commented Oct 14, 2026

Copy link
Copy Markdown
Owner

The transport and node-lifecycle half of 0.14.3: the defects the R3 review proved by running them. Each fix has a test that fails on a git archive of 632b95d and passes here. The CHANGELOG has the full account, under "Transport and node lifecycle" in the 0.14.3 section.

Fixed

  • R3-5, R3-9, R3-11: node lifecycle. start() and stop() are now steps on one promise chain, over four states (idle, starting, running, stopping). This is safe un-awaited, which XMesh relies on.
    • stop() while a start is running makes the start abandon and undo what it set up.
    • A failed start undoes everything and rejects. It is never an unhandled rejection.
    • start() after stop() takes the identity lock and the inbox again.
    • With nothing queued, a step starts synchronously, so stop() followed by exit still writes the inbox.
    • Relay URLs are checked in the constructor and in invites (EBADRELAY).
  • R3-4: records lost on a relay drop. Records the pacer never wrote are held for the peer's key when the connection closes or the session ends. They are offered again to the next session that proves that key, within the pacer's room, and listed in pendingDeliveries() until a socket writes them. The list is bounded.
    • Residual, documented: a frame the socket wrote that is lost after that.
  • R3-1/R3-2, client side: oversized messages. The ws ceiling is now 8 MiB and the frame bound is applied per message. An oversized message is dropped and counted, and the connection stays up. The roster is read past the bound.
  • R3-3: 4004/4006 were permanent stops. They are now retried on a long backoff (1 min doubling to 30 min) and logged loudly, with a ping-pong detector. relayIdentityRetry: false keeps MMP §4.4.7.
  • R3-10: the backoff resets on relay-peers, not when the socket opens.
  • R3-15: the liveness timeout terminates the connection.
  • R3-13: sessions close before the relay, so the 1010 notice is sent with no sequence gap.
  • R3-7: text corrupted at read boundaries. Lines are cut on bytes and decoded whole. The interior and the daemon refuse non-UTF-8 lines and bound them in bytes. The other readers use setEncoding('utf8').
  • R3-6: long socket paths. A Unix socket path longer than sun_path is mapped to /tmp/sym-<uid>/<digest>.sock. The daemon creates that directory (0700) and every client checks it.
  • R3-12: FrameParser copies a frame once, not per chunk.
  • R3-14: a socket's identity no longer trusts a birth time of 0. It adds the inode change time and a token in the file's times.

Needs a decision

  • The 4004/4006 retry departs from MMP §4.4.7 ("MUST NOT automatically reconnect"). It needs a spec ruling, as does a relay-side key proof, which is not implemented here.
  • mesh-channel still exits on identity-collision, so one spoofed 4004 still stops a channel host. It should exit only on info.pingPong.

Not here

  • R3-8: one identity in several rooms in one process.
  • End-to-end delivery receipts.
  • The receiver's own record budget, which drops directed records past 8 per second while the sender reports them dispatched.

Verified

  • Full suite at concurrency 2: 1560 pass, 0 fail, 4 skipped.
  • Stock-nodes gate passed.
  • npm audit: 0 vulnerabilities.
  • The new tests pass in node:22-slim and node:20-slim.
  • R3's relay runs were repeated on localhost against sym-relay 0.5.2 and the current origin/main (0.7.0).

…y-id upgrade

An XMesh review of lib/config.js 0.14.2 returned DO-NOT-SHIP with 13 findings. Each was reproduced in a
sandbox state dir before anything was changed; the data-loss one was also reported from a developer's machine.

What lost the key: migrateNodeDir refused to move a 0.13 node when nodes/by-id/<nodeId>/ existed, even empty or
holding only lock.pid, and left the name unindexed. loadIdentity then read "refused" as "no such agent", made a
new identity, renamed the 0.13 directory onto the new node's path and wrote the new identity.json over the old
one. A pinned start was bricked instead. The guard asked whether a directory existed; it must ask whether an
identity does.

Root causes, changed rather than patched:
- A second claim on a nodeId is a different identity (or content that cannot merge) already filed under it. An
  empty directory or a stale lock claims nothing: the node is merged into it. The same identity on both sides is
  an interrupted move and is finished. A real duplicate is still refused and nothing is overwritten.
- loadIdentity treated every answer it did not recognise as "mint". It now mints only when nothing on the host is
  known under the name: a 0.13 identity still under the name, or a name indexing a node whose identity is missing,
  halts (IdentityHaltError).
- The name is indexed before anything moves and the identity moves last, so a second starter finds the agent and
  an interrupted move is re-entered by the identity's absence. A step that fails because another process just did
  it is re-run; a path that became an alias of its destination is never merged onto itself.
- Key files: rename keeps the mode 0.13 wrote (0644). identity.json and e2e-keypair.json now end at 0600 and their
  directories at 0700 after a migration; loading narrows an existing key file, which heals nodes already migrated by
  0.14.0-0.14.2. A rewrite of identity.json is atomic.
- A move across file systems copies, verifies byte for byte, then removes the source.
- Smaller: an identity with a public key and no private key halts; the live-lock fallback checks the tombstone;
  a symlink that is not a by-id link no longer reads as already indexed; SYM_IDENTITY_DIR switched on for a node
  whose identity is in its node dir halts with instructions instead of making a new identity.

Not fixed, listed in the CHANGELOG: migrateIdentities still discards per-node errors; loadIdentity({nodeId, name})
re-points a name that indexes another node; SYM_IDENTITY_DIR first-time does not move the key for the operator;
an explicit nodeId unknown to the index is ignored when a new identity is made.

tests/identity-migration-by-id.test.js: 36 tests, 30 of which fail on 0.14.2. Four real processes starting one
0.13 agent at once, 320 starters: every one loaded the original agent. Seventeen mutants of lib/config.js, each
caught.
A name whose identity file is missing halts and names what to remove to start the name again as a different
agent. It named only nodes/by-name/<name>.json, but nodes/<name> is a link to the old by-id directory and
re-indexes the name from it on the next start, so removing the index alone halts again. The message now names
both, and a test follows the message to the end (the first removal is shown not to be enough).

The test count in the CHANGELOG is now 37 (31 fail on 0.14.2).
…n lost the key)

The merge into an existing by-id directory was safe only if no other process was doing the same. It assumed
each step was idempotent, so several processes could interleave them. They cannot: when one finishes it
replaces nodes/<name> with the compat link, and another, still holding the old path, then sees that path lead
to the destination, finds the "duplicate" file identical to itself and deletes it - the only copy of
identity.json. Plain concurrent runs passed 320 starters. A fuzzer that stalls every file call for a few ms
in four real processes lost the identity file in 2 of 300 rounds (everything else filed, identity.json gone).
A Linux run under a throttled CPU (node 22, 0.3 cpu) failed the new race test twice in 40 with the same
symptom: identity.json missing from by-id after every starter had finished.

The design assumption was "a retry makes concurrent migration safe". It does not, and a guard on the alias
only narrows the window. The migrator now holds a lock (nodes/.migrating/lock.pid, the identity lock) while it
moves something; others wait, read the state again on their turn, and find it done. It is taken only when a
0.13 directory is there to move. The retry loop and its ENOENT tolerance are gone; the alias check stays as a
last guard on a key file. With the lock the fuzzer ran 1,200 starters (300 rounds, husk) and 1,600 (400 rounds,
none) with every starter loading the original agent and no errors.

Also fixed in this commit: the F6 test passed for the wrong reason once the lock existed (its injected write
failure hit the lock file's write); it now fails only the identity temp file. A starter that read the index
just before another process wrote it, and then migrates nothing, re-reads the index instead of making a new
identity; that line had no test and now has one. Tests: 41 (33 fail on 0.14.2); 17 mutants, each caught.
…g it waited

The test asserted the second start waited at least 250 ms of a 400 ms hold. Under a throttled cpu (node 20 in
a container at 0.3 cpu) the holder's timer had run for 150-170 ms before the test process was told it held the
lock, so 4 of 30 runs measured 225-243 ms and failed although the start had waited. It now has the holder
report the time it let go, and asserts the start returned at or after that time (a start that moved the node
alongside the holder returns well before it). Mutant "no migration lock" still fails it.
loadIdentity ran the migration only for a start by name alone (`!nodeId && name`). mesh-channel starts every
node as { name, nodeId: <the pin in node.json>, create: false }, so a pinned node whose files were still the
0.13 nodes/<name>/ could not start (EIDENTITYABSENT) until something started it by name; with the default
create: true, 0.14.2 made a new identity and lost the key, and the first 0.14.3 draft halted. Reproduced in a
sandbox state dir on fe285a3 and on the branch before this change (plain, empty by-id/<pin>, create true/false).

When the pin's identity is not in place and a 0.13 directory under the name is still there, loadIdentity now
runs the same locked, idempotent migrateNodeDir(name) and goes on to load the pinned agent. It is one path:
the unpinned and pinned starts share the block; migrateNodeDir takes the pin as an optional second argument.
It is not run when there is no 0.13 directory to move, so a pinned start writes nothing it did not before.

Decision, a pin that is a DIFFERENT nodeId than the directory's: that directory is another agent's, so it is
left where it is, unindexed (migrateNodeDir answers 'skipped-duplicate' before it moves or indexes anything).
The pinned identity stays absent and the start fails as before: IdentityAbsentError with create: false, and
with create: true the halt that names the stranded 0.13 identity and the pin. Nothing is lost and nothing is
made. That agent moves when it is started by name or by its own pin.

Tests (tests/identity-migration-by-id.test.js, 51 now): plain and empty/locked by-id/<pin> with create false;
create true loads the original and does not mint; SYM_IDENTITY_DIR; a pin that is another agent's; a link to
another agent does not get indexed by a pinned start; the name indexed to another agent; a live 0.13 holder and
the tombstone; four real processes with injected stalls, with and without a by-id dir already in place. Eight
of them fail on 0.14.3 as pushed (051f44b) and on fe285a3; two guard against over-migrating. Four new mutants
(no pinned migrate, no pin guard, no legacy-dir gate, adopting the index) are each caught.
… bounds, backoff and identity retries

R3-5, R3-9, R3-11: start() and stop() are steps on one promise chain over four states (idle,
starting, running, stopping), safe un-awaited. stop() during a start makes it abandon and undo what
it set up; a failed start undoes everything and rejects (never an unhandled rejection); start()
after stop() takes the identity lock and the inbox again. discovery.stop() lets go of its server
before it waits. A relay URL is checked in the constructor and in invites (EBADRELAY).

R3-4: records a relay pacer never wrote (the connection closed, or the session ended) are held for
the peer's key, offered again to its next session, and listed in pendingDeliveries() until a socket
writes them. Bounded; the residual (written and then lost) is documented.

R3-1/R3-2: the ws ceiling is 8 MiB and the frame bound is per message; an oversized message is
dropped and counted, the connection stays up, and the roster is read past the bound.
R3-3: 4004/4006 are retried on a long backoff with a ping-pong detector (identityRetry: false keeps
MMP 4.4.7). R3-10: the backoff resets on relay-peers, not on open. R3-15: the liveness timeout
terminates. R3-13: sessions close before the relay, and its last flush sends the 1010 notices.
…ty without birth times; linear frame parsing

R3-7: lines are cut on bytes and decoded whole (lib/line-reader.js) in the interior and the daemon,
which refuse a line that is not UTF-8 and bound lines in bytes; the CLI, SymDaemonClient and the
child-process readers decode with setEncoding('utf8'). The interior no longer signs text damaged by
a read boundary.

R3-6: a Unix socket path longer than sun_path is mapped, the same in the daemon and every client, to
/tmp/sym-<uid>/<digest>.sock in a 0700 directory the clients check before dialling.

R3-14: a creation time of 0 is unknown; a socket's identity adds the inode change time and a token
the daemon writes into the socket's times.

R3-12: FrameParser keeps its chunks and copies a frame once, when it is whole.
…ose keeps its queue; the ping-pong holds

An independent read of the diff found:
- a re-offer stopped by the pacer's room with nothing of its own waiting never ran again: it now
  re-arms on a timer, and checks the pacer's room (half of each bound spare) before it offers, so a
  re-offer never makes the pacer refuse a sealed frame (which ends the session);
- taking back the records queued before a sealed 1010 turned the notice into a sequence gap at the
  peer: a session that sent the notice keeps its queue, which its peer can still open;
- the ping-pong detector's window equalled the longest backoff, so a real ping-pong fell out of it,
  and the host was told on every close: the ping-pong now holds until a connection outlasts 70 min,
  and the host is told at the first close in the window and when a ping-pong is found;
- records came back to a stopping node and were held after the stop dropped the rest: anything
  handed back to a node that is not running is said undelivered at once;
- re-offers no longer go to a relay client session still waiting to supersede (offered when it
  does); a failed start is 'stopping' while it undoes; start() on a running node with nothing queued
  queues nothing, so a stop() in the same turn still starts at once; the redelivery metrics never
  throw into the teardown.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 14, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-14T16:03:32.956394Z 1ac9934 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 1ac9934ab7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread lib/node.js
Comment on lines 845 to +846
this._relayUrl = opts.relay || null;
assertRelayUrl(this._relayUrl, 'SymNode: relay');

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate the relay URL before taking the identity lock

When an embedder catches EBADRELAY, the constructor has already acquired the identity lock earlier in the constructor, but the partially constructed node is inaccessible and cannot release it. A subsequent node for the same identity in that process receives only the same-process no-op release, so even stopping that node leaves lock.pid held until process exit, preventing another process from using the identity. Move this validation ahead of identity loading/lock acquisition or explicitly release the lock on failure.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant