Lwd-mixnet-proxy: light-wallet gRPC over the Nym mixnet, and what three days of measuring it found

@maxnym thanks for coming in. Three things from the Nozy / dialling-half side.

1. Reliability layer (probe / watchdog / open-3)

Glad you called this what it is. On our side it was never “debug until the demo works” it is how a wallet-shaped caller survives silent first-payload loss. @Joaco owns the proxy design; we ran the external client half and published the numbers (Aug 15 load → PR #3; dead vs live midnight windows; GetLightdInfo over :9068, not TCP-only).

We are happy to support turning that into something the SDK can run modules through. Joaco already said yes to writing the layer and bringing wire format as an issue on nymtech/nym first. From Nozy: we will keep producing independent client-half evidence (same gateway class, different machine) so any PR has a second measurement path, not only Joaco’s serving half.

If you want a short design note from the wallet caller’s view just let me know as well.

2. Threading no node with identity … is known up the stream

Yes, please do that. Same class of bug we hit when the far end is gone from topology: SDK already knows (WARN), caller gets silence until its own deadline. A catchable connection error would have saved a night of guessing. The “gateway alive, nobody home at that address” case Joaco split out can stay opaque; the known-identity miss should not next one.

3. Rerun on develop + what we still need from you

Joaco’s [#20] correction (orphan buffer / #7057 — 0/400 on develop vs ~21% on 1.21.5-rc.3) matches a suspicion on our earlier 55–60% per-stream bench. We will rerun the dialling-half load on develop when the serving half we point at is on that pin, and post evidence the same way as Aug 15/20 (metrics + whether first-round / unestablished move).

I had a question about the fast mode pin to get this started.

  • Can you rephrase this please? I don’t quite get what you mean.

I went back to the code and I had it wrong: it does not bite where I assumed. A stream attaches the
budget twice before the far side answers, once on the Open and once on the first write, so the echo
side holds 20 against a threshold of 10 and can reply. Where it does bind is a reply owed after a
single message on the defaults: 10 attached against 10 held back, nothing spendable, and the receiver
has to ask for more before it can answer at all. Are those two tens on purpose, or did they drift
into each other?

  • I’ll check into this sure:

One neighbour while you are in there. send_to_stream warns and skips ahead when the reorder buffer
passes 256.

I’m also happy to see a design doc / discuss it in here as well, instead of spreading discussion across multiple platforms.

1 Like

One other point I missed earlier:

The question underneath: is there a supported way to keep one dialable address across a gateway
change? The answer would change how we run this.

No, this isn’t possible right now, due to the way that the [addressing scheme](Nym Network Addressing | Nym Docs) works.

1 Like

@Joaco with regards to the loaded data is inconsistent point of yours, can you please send me the logs preceding that line the next time you see it?

There are two paths in the surb backend that produce that message, and I’d like to see whether I also see either Data flush is complete or failed to flush our reply-related data to the persistent storage:followed by an error.

@maxnym, that paragraph deserved the question. Let me take it apart.

A receiving client holds back minimum_reply_surb_storage_threshold reply blocks. The default is 10. It only spends what it has above that. The amount it can put into a reply is available - threshold.

DEFAULT_NUMBER_OF_SURBS is also 10. Take a peer whose entire stock is one default message’s worth. It holds 10 against a threshold of 10. Nothing is spendable. It cannot answer until it asks the sender for more and gets them. That is a round trip before the first byte comes back.

That bites on a single anonymous message with a reply owed: one send_plain_message, carrying the default 10, and one answer. It does not bite on the echo exchange I measure. That is the part I had wrong when I first wrote it. There the far side reads before it writes, so two messages have landed before it answers: the Open, then the first write. Both carry the budget. The peer holds 20 against a threshold of 10, spends 10, and replies. A stream that writes as soon as it accepts, before the first Data arrives, is back to 10 against 10. And the threshold is per sender tag rather than one reserve for the whole client.

So the question is only about the one-message case: are the two tens meant to be equal? At equality, the first reply of a fresh exchange can never go out directly. I could not tell from the source whether that is intended or whether the two constants drifted into each other.

On the inconsistent store, I do not have to wait for the next one. It is in the 20 Aug log, and neither line you named is there. Across 52,411 lines covering every restart, PersistentReplyStorage is flushing all reply-related data appears once, Data flush is complete never, and failed to flush our reply-related data never. The sqlite pool was closed 40 microseconds after the flush started, from nym_sqlx_pool_guard.

One more thing from the same log: that single flush line covers five loaded data is inconsistent. The other four starts died during startup with nothing to flush, and the start after each still called the store inconsistent, so whatever marks it dirty survives until a clean shutdown that never arrives. The window is in the raw log I linked yesterday, and I can send it unfiltered if that reads more easily.

One correction to the summary above, before it settles as the record: the wire proposal is withdrawn. I made it in post 19 and retracted it in post 20 the same evening, once the measurement showed the residue it rested on was the Open/Data race you had already fixed. LaDale, the recap has me bringing a wire format issue to nymtech/nym, and I am not. If a run on a tree with the orphan buffer still shows something unexplained, I will bring it back with the numbers attached.

The layer itself stands, and yes to keeping it here rather than scattered. I will post a design note in this thread. It waits on one thing: a long run on develop, counting rescued frames and streams whose Open never arrives. Six thousand trials overnight: a ceiling near 0.05% on a loss rate I have not seen at all yet. Then the note can say what the layer is for on fixed code, rather than what it was for on the release I pin.

On addressing, thank you. That is the answer I needed even though it is a no. It splits our gateway failures in two. One is survivable: on 20 Aug ours restarted and the process came back on the same registration and the same address, five restarts and 73 seconds. The other is not: a gateway that goes for good means re-registering, and re-registering means a new address that every wallet holding the old one has lost. So anything public on the mixnet needs an out-of-band way to publish its current address, and that is wallet and operator territory rather than transport.

LaDele, I will put the public serving half on a build of develop at ece291d, the revision I measured, so you can rerun against it. I will post the address and the window when it is up. Worth saying what that buys beyond your rerun: the orphan buffer’s bounds are finite, a 5 second TTL swept periodically so retention runs a little past that, 64 streams, 32 frames each. Days of real traffic is a different test from a bench run, and I have not seen one reported.

One question, Max, and it is for our own pinning rather than for you to act on: does the fix have a release, or a target? We are choosing between staying on 1.21.5-rc.3 with the probe compensating and moving the dependency to a develop rev, and the answer decides it.

1 Like

This might just be an oversight on my part - the # of SURBs to send with each message is configurable in the Destination struct, so you can experiment there.

One more thing from the same log: that single flush line covers five loaded data is inconsistent. The other four starts died during startup with nothing to flush, and the start after each still called the store inconsistent, so whatever marks it dirty survives until a clean shutdown that never arrives. The window is in the raw log I linked yesterday, and I can send it unfiltered if that reads more easily.

Are you shutting your client down properly (aka using disconnect() and waiting)?

The layer itself stands, and yes to keeping it here rather than scattered. I will post a design note in this thread.

Great, look forward to seeing it.

One question, Max, and it is for our own pinning rather than for you to act on: does the fix have a release, or a target? We are choosing between staying on 1.21.5-rc.3 with the probe compensating and moving the dependency to a develop rev, and the answer decides it.

I published 1.21.5 today - use that. Our release cycle goes in waves, so I will notify you once I have the fixes I have proposed today for the SDK in there and they’re going through QA so you can point at a git rev until they’re released.

One note: don’t use the develop branch if you’re pointing at a git rev instead of using a release from crates.io. developis always going through QA and will change, so won’t be stable; we have our platform and SDK code in the same monorepo.

I’m happy this coming together as well in the open. Max thanks for the update version when Joaco ready we can run it.

Max, one thing to check before I move: 1.21.5 does not carry the orphan buffer.

I pulled it from crates.io. Its src/mixnet/stream/ is byte for byte the same as 1.21.5-rc.3, there is no occurrence of orphan anywhere in the crate, and the branch that drops a frame for an unregistered stream is still the bare else in the same place. #7057 went into develop on 14 Aug and 1.21.5 published on 19 Aug, so I expected to find it there.

Is it expected in the next one, or would it need a backport onto the release branch? Not blocking for us either way: the probe covers it.

On disconnect(): no, we do not call it. Our shutdown drains the streams it is carrying and then the process exits. The client never gets a proper goodbye. That one is ours and I will fix it.

For the 20 Aug event specifically, the log has the shutdown starting inside the SDK rather than in our code, all within two milliseconds:

13:05:57.176555  assuming the gateway is dead
13:05:57.176581  Signalling shutdown from the MixTrafficController
13:05:57.176937  PersistentReplyStorage is flushing all reply-related data
13:05:57.177960  lwd_mixnet_server: the mixnet listener closed

Our binary learned about it on that last line, after the flush had already started, so on that path the application has no window to disconnect politely, whatever it does on its own SIGTERM. Both things can be true. I will fix our half regardless.

Thanks for the Destination pointer. I have a six thousand trial run going against develop right now, counting rescued frames and streams whose Open never arrives. I will add a sweep of the SURB count through Destination and post both together.

LaDale, that changes what I promised you. I would rather wait for a release with the fix than point the public half at develop, so the deployment side of your rerun waits for that.

1 Like

It will be in the next release - we do not publish from develop, develop will be in QA for the next release currently, then we will cut a release branch from it and run the crates.io publication from there. If you want to point at that, fine, but be aware that the branch is in the process of being QA-ed.

Or do you mean the orphan buffer is in the -rc but not the newest one? In which case I will look into it. Which file is it in?

2 Likes

Not a regression: neither release has it. src/mixnet/stream/mod.rs is identical between 1.21.5-rc.3 and 1.21.5, 504 lines in both, and send_to_stream starts at line 125. The branch I mean is the else at line 165, where a frame whose stream id is not in the map falls through and nothing is logged.

On develop the same file is 731 lines. It gained an orphans map beside streams, a buffer_orphan that holds frames whose stream has not registered yet, ORPHAN_TTL at 5 seconds, MAX_ORPHAN_STREAMS 64, MAX_ORPHAN_MESSAGES 32, and a register_stream that drains whatever was held for that id. In the monorepo it is sdk/rust/nym-sdk/src/mixnet/stream/mod.rs, and it came in with #7057.

So my expectation was wrong, not your release. Your description of the process answers it: it is on develop, develop becomes the next release branch, and the fix rides that. That is all I needed for the pinning decision. We stay on 1.21.5 and the probe keeps covering it until the next release lands.

1 Like

There’s a branch here you can use as a git rev both: GitHub - nymtech/nym at max/lwd-stream-patch · GitHub , it contains a first pass at the changes to the SDK we’ve discussed above.

I had a look @ the diff between Como and develop and it looks like it should be fine to experiment with - lmk if there are issues though and I could always replay these Stream fixes on Como or something if necessary.

1 Like

Max, thanks for turning both of those around in a day.

What is left

Three of the four things behind the probe now have answers: the Open/Data race on develop, the unroutable recipient failing at dial time, and the reorder gap raising an io::Error past the byte cap.

Two are left. open_stream returns without anything confirming the far side accepted, so the caller holds a stream that looks established and may be nothing. And there is no teardown: a stream I discard is one the listener holds until its own deadline. That second one I should have said last post, and it bites this design directly, because the layer’s failure path is to discard. It is already on your known-limitations list, so I am naming it, not asking for it.

Two smaller ones, so I am not overstating what is fixed. The routing check is dial-time only, so a gateway leaving mid-conversation still drops writes with the warning a layer below. And the orphan buffer is bounded, so past 5 seconds, 64 streams or 32 frames it drops without a word.

What I measured

6,237 trials on develop, no unexplained loss (docs/measurements/2026-08-25-six-thousand-trials.md). Then 1,000 on your branch this afternoon: 997 ok, no stream data lost, 207 frames rescued. The three failures are the interesting part. Consecutive, all at the 20 second deadline, and all three replies arrived afterwards, 22 to 72 seconds after their requests.

So in these runs I saw no transport drops. What there is instead is a tail past a minute, and a deadline against that tail turns healthy streams into failures and dead ones into waits. Everything below follows from that.

What the layer does

Verified establishment. Hand the caller a stream that has answered, not one that has been sent. On failure, discard rather than retry on it: the state left on the far side is unknown, and resending a request that may already have been delivered is worse than failing cleanly.

The counter pair. Streams opened, counted separately from connections established. The gap says whether a deadline is clipping healthy streams. It will not tell a slow peer from a dead one. It is still what I would keep if only one thing survived: it told me my attempt budget was calibrated for a rate that no longer exists.

Not rounds and retries. Across 7,237 trials on fixed code I cannot show you the independent per-stream failure they answer. A retry would have absorbed today’s three failures, and would also have spent twenty more seconds hiding the deadline that caused them.

The part I have not solved

Without an acknowledgement on the wire a probe is bytes in the stream, so both ends must agree a convention. Fine in a closed system. The first question you would ask of an SDK feature.

Three options, and I owe you the one I skipped. A caller echo, which is what we run and pushes the contract onto every user. An ack of the Open as a control frame, which I ruled out in post 19 because it would pass streams whose payload was already lost. That reason died with the race. An ack of the first payload, which stays withdrawn for want of evidence.

The first two prove different things. An SDK ack says the peer’s SDK registered the stream. An echo says the application is alive and reading. I do not know which you want the SDK to answer.

Shape, if you want it

A wrapper rather than a change: open_stream, a deadline, a caller-supplied confirmation, the two counters. It composes with your branch instead of duplicating it. Dialler-side only, so the listener’s half of teardown is not in it.

The unroutable case, dialled once against each build this afternoon:

build what the caller got
1.21.5-rc.3 nothing at the caller, two no node with identity warnings below, a connection that sits there
max/lwd-stream-patch cannot route to HDfv77... from open_stream, immediately

Still owed from post 29: the SURB sweep through Destination. It goes in the next run.

If this shape is wrong for where you want it to live, tell me and I will write the other one.

  • So what you’re requesting is some kind of initial handshake so you can tell the receiver is up/alive, which you can use prior to starting sending streams of data? Or is there anything else? If its just that that seems easy enough to implement something quite quickly.

  • Two smaller ones, so I am not overstating what is fixed. The routing check is dial-time only, so a gateway leaving mid-conversation still drops writes with the warning a layer below

    To be honest I’m not sure how you’d immediately solve that one, but I also don’t know whether the Stream abstraction is necessarily the place for that; I think handling mid-stream drops might be something for the consuming library. Otherwise, I could look into it, but IMO it’ll be quite a task that I think a consumer might already implement.

  • And the orphan buffer is bounded, so past 5 seconds, 64 streams or 32 frames it drops without a word.

    I could put a warning or something on there, but similar to my response above, I’m not sure whether the consuming library could already handle a 5 second drop w/out restarting the stream and retrying its sends. What do you think?

    Note: I am OOO tomorrow and Friday, but I will be back at my computer on Monday. Just a heads :slight_smile:

2 Likes

Hey @Joaco and @maxnym,

Following up on #21: external dialling-half load against Joaco’s current testnet address, for cross-check with your serving-half metrics.

Setup: client rebuilt with nym-sdk 1.21.5 (crates.io, per #27); gateway 6PkVkJ8nq882V1C95uCHUUoBWgo1ZzWHVpDtrhSjwDVn. Window: 2026-08-26T22:45:51Z – 22:47:54Z. Load: 35 sequential GetLightdInfo via :9068 (verify_lwd) gRPC, not TCP-only as on Aug 15.

  • connections_total 83 → 118 (Δ 35)
  • connections_unestablished_total 1 → 2 (Δ 1)
  • first_round_failures_total 2 → 3 (Δ 1)

Rates (Δ / Δconnections): first-round 2.86% (1/35); wallet-visible unestablished 2.86% (1/35). Aug 15 on 1.21.5-rc.3 (TCP-only, gateway 3xLD3rpA…): 16.67% / 6.67% on 30 connections. Pin, gateway, and load method all changed we are not claiming a single cause but the direction matches #20.

gRPC: 34/35 ok (testnet; exit 2 without donation UA counts as ok); 1 transport error; p50 ~2.6 s. Evidence: NozyWallet repo docs/reference/evidence/ (20260826-174551).

Operator measurement only Nozy does not default to :9068 (#24). D2c-live and fast-mode C3 remain separate open tracks.

Hey Joaco did upstream_connections_total move on your side in that window, and which SDK pin was the serving half on? Max I’m happy to post the wallet-caller reliability note in-thread (#23) if still wanted. Enjoy your time away as well see you Monday I’m break for a while myself and enjoy my break from school,

Max, answers to all three, and the SURB numbers I owed you.

The handshake

Yes, that is the whole request. We need to know that a stream we just opened has reached something alive, before the caller commits a request to it.

The cheap version is enough: an ack that says the peer’s SDK registered the stream. That already rules out the two failures that cost us the most, an address whose gateway is gone and a process that is not running. I would not ask for more than that.

The gateway that leaves mid-conversation

You are right, and it was never a request from me. Deadlines cover it on our side. I listed it in the design note so I was not overstating what your branch fixes.

The orphan buffer bounds

The warning is worth it for whoever is debugging, which is most of what this thread has been. But it is a log line, not something a consumer can act on, and I would not want it to be the trigger for anything. We give up on a stream through our own deadline, not by reading logs. If you want it actionable it has to arrive as an error, the way the reorder overflow does on your branch.

The retry I would not do either, and I would not want a consumer doing it blindly. Once a stream has lost frames the far side’s state is unknown, and resending a request that may already have been delivered is worse than failing cleanly.

The SURB thresholds I owed you since post 19

DEFAULT_NUMBER_OF_SURBS and minimum_reply_surb_storage_threshold are both 10, and the overlap costs nothing measurable.

I built the worst case I could: budget 1, so the receiver holds two reply blocks against a threshold of ten, and a fresh dialling client for every trial so nothing carries over and each exchange is a first exchange. 100 trials per arm, alternating blocks of 50 so that neither arm sits entirely in one window of the afternoon.

threshold 0 default 10
failures 30% 33%
p50 4,188 ms 4,662 ms
p90 8,305 ms 8,750 ms

A forced round trip before the first reply would cost roughly a second and a half at this budget, going by what a budget 1 exchange usually takes. The gap is 474 ms, and it does not even hold its sign: the default arm was 490 ms slower in the first pair of blocks and 380 ms faster in the second. Between its own two blocks the threshold 0 arm moved 850 ms and the default arm 1,720 ms.

So what I can say is that this run found no consistent difference, not that the collision is free. It is worth adding that in the default arm the replenishment definitely happens: two stored blocks never exceed a reserve of ten, so every first reply there goes through a request for more. Whatever that costs, it is smaller than an afternoon’s drift.

One thing fell out of it that I did not go looking for. A fresh client’s first exchange at budget 1 fails about 30% of the time in both arms, and 62 of those 63 failures are on the way back, with the reply already written by the far side. My guess is that the receiver is simply short of reply blocks: with two of them, or with ten requested and granted, there is very little left to retransmit with once the first attempt goes missing. An established client carries thousands and never notices.

I did look at other budgets, and they do not back me up. An earlier fresh-client run across budgets 1, 20, 100 and 400 failed 3, 3, 1 and 2 out of 20. If reply blocks were the whole story, 400 should have been far safer than 1 and it was not. Twenty trials a row is too few to conclude anything, but it is the evidence I have and it does not point where I was pointing.

The reconnection PR

Open as #7109, the one I offered in post 19: two settings that exist in the gateway client but cannot be reached from DebugConfig. Defaults unchanged.

It grew one thing while I wrote it. The settings have to be applied after the two construction paths in start_gateway_client converge, because a client upgraded from the registration handshake keeps the config it registered with. That path is what an ephemeral SDK client takes every time. Doing it there also fixes gateway_response_timeout, which was set on the other path only.

LaDale

Yes, it moved, by 35. Every one of them carried 202 bytes up and 259 back, so all 35 of your calls reached the upstream and came back. That figure is a count from the container log rather than a scrape of the counter, so it will not line up against your deltas the way your own three do. The interesting one is the last: it closed at 22:49:49Z, nearly two minutes past the end of your window. That is almost certainly the transport error you counted, finishing here long after your client had given up on it.

The pin you asked about: the serving half is on 1.21.5-rc.3, not 1.21.5.

Before that comparison settles in the thread: src/mixnet/stream/ is identical between 1.21.5-rc.3 and 1.21.5, so neither your new pin nor my old one carries the orphan buffer. The 16.67% to 2.86% is real, but it cannot be that fix.

That rules out the buffer, not the pin. The two crates do differ in three files, all of them around nym-api URL handling, so the pin stays on your list alongside the gateway and the load, which went from TCP-only on 15 Aug to real gRPC now.

1 Like

Immediate idea: I’m going to add a configurable ping/pong liveness check and add a getter function onto the stream, so the consuming crate can check it and not have to rely on timeouts alone. This will happen on a different branch though, and only go live later, since it involves changes to some infrastructure code as well, so will have to wait on a release cycle before it goes live. Since this isn’t blocking, then your use of timeouts works fine for the moment as well (since you will only be modifying your code slightly to probably check the getter on a timeout loop instead of just relying on a timeout to classify a stream as alive/dead I imagine).

Will get a version on the branch for you today

2 Likes

@ both - there is now an aliveness handshake in the GitHub - nymtech/nym at max/lwd-stream-patch · GitHub branch. Let me know if that works. The ping/pong keepalive is in progress.

3 Likes

@ both you could also switch to GitHub - nymtech/nym at max/stream-keepalive · GitHub if you want - this is the lwd-stream-patch branch with an additional ping/pong keepalive. Let me know what you think.

If this works for you and covers your requirements, then I can go back to the team and start the review/merge process.

3 Likes

Max, it works. Five hundred trials at f5e46b7d, each waiting for the acknowledgement before writing anything:

echo came back echo failed
acknowledged 500 0
no acknowledgement 0 0

Both off-diagonal cells stayed empty: discarding an unacknowledged stream would not have thrown away anything healthy. It costs half a round trip, p50 1,551 ms. Your split counts run 2,985 ms end to end against 3,035 ms for the flat 10 they replace, interleaved in that same run. Nothing measurable between them.

Then ten dials at an address whose client registered and disconnected, so its gateway is alive and in the topology and there is nobody behind it:

dead peer: 10 dials | opens refused 0 | acknowledged 0

Fifteen seconds and an error, instead of a stream that looks established until my own deadline fires. That is the one I could never see from the dialling side.

One number from a second run, in case it helps the review: with a fresh dialling client per trial, budget 1 puts the acknowledgement about two seconds behind budget 10, twice, 2,105 ms and 2,118 ms. Only those trials log the acceptor with no reply blocks left, and none to ask for more with.

A correction I owe the thread

The 30% first-exchange failure rate in my last post is not real, and neither is the fragility I hung on it. Those failures are not spread through the run. Each of its four blocks ends in one unbroken run of them, 58 of the 63 in total, and each block was a separate process, so the number records where each process fell over. Before those collapses the rate is 5 in 142, and the two runs I did last night fail 0 and 1 in their first hundred trials.

What does happen: with the dialling client rebuilt every trial the acknowledgement slows down and then everything stops. 3,031 ms in the first twenty trials, 4,051 ms in trials 81 to 100, and from 107 nothing at all. Twice last night, at 107 and at 105, neither recovering. With one dialler reused, 500 trials never left the range 1,527 to 1,607 ms and none failed.

That is an association and not an isolated variable, since a rebuilt dialler brings a registration, a teardown, another gateway and route, and a topology fetch with it. What I can say is that the failures are in the return direction, bar two deep in one tail, and that the accepting client is the only thing alive across a whole run. It matters beyond the rig, since a public serving half meets that shape of traffic by definition. The write-up is in docs/measurements/2026-09-01-establishment-handshake.md, raw logs beside it.

Tearing down a dialling client at the end of that run hit the panic! at gateway-client/src/packet_router.rs:70, with nym-api serving a topology out of two epochs. That run built and destroyed 140 clients in one process. Nobody does that outside a test rig, so I am not calling it a bug.

The keepalive branch

The keepalive branch covers what I asked for. So does the acknowledgement on its own: I needed to know a stream had reached something alive before committing a request to it. PeerUnresponsive goes past that, and it is the shape I said I would need for anything to be actionable. Nothing is waiting on me here.

I have not measured that branch. My streams live about three seconds, so they never sit idle long enough to be pinged. Testing it means holding one open and taking the far side away mid-stream. I will build that and post what it does.