fix(pull)!: report every way a send can fail, instead of hiding four of them - #555
Merged
Merged
Conversation
Running `/pull-lab` against a live portal turned up a defect the harness was not
looking for: `PullClient.sendMessage()` resolved as if the message had been
accepted, nothing ever arrived, and nothing anywhere said why.
Two independent faults lined up.
`sendMessageBatch` started the channel-id lookup and **dropped the promise** —
no `return` — so the method resolved `undefined` before the lookup had answered.
The caller was told "sent" while the send had not begun, and any failure inside
became an unhandled rejection nobody saw.
`ChannelManager.getPublicIds` caught the REST refusal and resolved `{}`. An
empty channel map is a *valid* input to `encodeMessageBatch`, which then built a
message addressed to nobody and handed it to the push server, which dropped it
in silence.
An existing spec pinned that second behaviour, on the stated grounds that "a
failed channel lookup must not reject into the caller's pull loop". That path
does not exist: `getPublicIds` is reached only from `sendMessageBatch`, reached
only from the public `sendMessage` / `sendMessageToChannels`, and neither has an
internal caller anywhere in the SDK. Nothing in the receive loop can reach it.
The case is inverted rather than deleted, with that reasoning recorded on it.
The underlying cause is not a fault at all, and is why the rejection names it:
`pull.channel.public.list` is not part of the application REST surface.
Bitrix24's documentation says an application's Pull client is receive-only — the
back end publishes with `pull.application.event.add`. The SDK's own Pull page
already said so; it just also exposed two methods that could not honour it.
Also from the same portal run:
**The exit criterion in `pull-protobuf.md` is now met on the decode side.**
`test/integration/pull/fixtures/response-batch-frames.json` holds five frames
the push server produced — 22 157 bytes, one of them 20 424, past the
three-byte length prefix — and `real-portal-frames.unit.spec.ts` decodes each
with both codecs and compares what the client reads. They agree. Checked by
mutation: reading `created` as a varint, or truncating the body by one
character, reddens it. What the fixture is *not* is written down too — encode is
still unreachable from an application, and no stats frame was ever captured, so
the `oneof` still rests on the differential suite.
One substitution in the fixture, in place and equal in length: the portal
hostname in `extra.server_name`. Every length prefix and every other byte is the
server's.
**The lone surrogate is measured rather than assumed.** `pull-protobuf.md` said
a lone surrogate could not reach the codec because JSON escapes it. The portal
answer is blunter: it makes `pull.application.event.add` fail with
`Wrong authorization data` before anything is published. It had been sitting in
the lab's fidelity battery, where it failed that whole check and hid fourteen
values that passed — it now has a check of its own.
Docs: the new code is on the Error Codes page, and the Pull page says plainly
that the two send methods cannot work in an application and what to use instead.
Unit suite 92 files / 1145 tests; lint, 11 typecheck passes and the docs gates
clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr
IgorShevchik
force-pushed
the
claude/pull-publish-silent-failure
branch
from
September 22, 2026 10:42
5ca227f to
b51126c
Compare
… never happened Five reviewers went over the first revision. Three findings changed the code, two changed what it claims. **The fix had a hole the tests could not see.** A REST refusal reaches `ChannelManager` in two shapes: a thrown `AjaxError`, and a RESOLVED non-success `AjaxResult` whenever `isSoftError()` is true. The rejection only happened on the second path because reading `.result` off `getData()`'s `undefined` threw a `TypeError` — it worked by accident, and it logged that `TypeError` instead of the portal's own message, so on that path the change still did not answer "why did my message go nowhere?". Both deliveries are now handled explicitly, and a successful answer that yields no channel for any requested user rejects too, rather than resolving an empty map one level up. The tests could not see it because they replaced `actions.v2.call.make` wholesale with a hand-built fake, cutting out the retry loop and `isSoftError()`. The seam is now `vi.spyOn(httpClient.ajaxClient, 'post')` as `testing.md` specifies, so the real `HttpV2.call()` builds what the SDK would really hand over. Restoring the old silent drop through the soft-error path used to leave the whole suite green; it now reddens. **The error carries its cause.** `originalError` was dropped, so a caller could not tell a permanent refusal — the method is not in the application REST surface, retrying is pointless — from a 503, where retrying is right. The description asserts the former and cannot distinguish them alone. **`sendMessageToChannels()` does not reject, and two documents said it did.** It carries `channelList` and no `userList`, so the lookup is never reached. A reader following the Error Codes page would have written a catch for something that cannot arrive. Corrected, and pinned by a case. The same short-circuit means `sendMessage()` does not reject either when every channel is cached; that is now stated rather than implied. **The real-frames test was green on a real divergence.** Its normaliser coalesced every field with `??`, which erased proto2 *presence* — the one property a proto2 codec gets wrong. `Sender.id` is absent on the wire in every frame: protobuf.js reports it absent, the lite codec materialises an empty `Uint8Array`. Deleting the lite codec's whole default initialiser also left 1145 tests green. The comparison no longer defaults the scalars, the known divergence has its own case, and the defaults are pinned by a synthetic frame in the differential spec — no recorded frame can cover them, because every real message carries all four fields. Also, from the panel: - the dead `!this._channelManager` guard is gone; it was a bare `Error` on an unreachable branch, which the checklist forbids; - both public methods' JSDoc now says they can reject, with what code, and what an application should use instead — that tooltip is what a caller reads at the moment they type the method; - `3.within-2x.md` gains a section, as the repo's own process requires for a call that used to return and now throws. It leads with the counter-example the panel found: a heartbeat loop with no `catch`, on a transient failure, used to lose one message and carry on and now takes the process down under Node 15+. It also records the success-path change, which the first revision did not mention at all — `sendMessage()` used to resolve `undefined` immediately and now awaits the lookup and the send; - the fixture is two frames rather than five (three were byte-near-copies), its placeholder hostname is a real TLD rather than a truncated one, and it now carries `regenerating` and `knownGaps` fields — nothing said how to make another one, and the naive redaction corrupts four nested length prefixes; - the inverted case in `pull-channel-manager.unit.spec.ts` no longer sits under a header asserting the behaviour it deletes. BREAKING CHANGE: `PullClient.sendMessage()` rejects with `JSSDK_PULL_PUBLIC_IDS_UNAVAILABLE` where it used to resolve, and resolves the connector's send result rather than `undefined` immediately. A caller with no `catch` sees a rejection where it previously saw a silent no-op. See `3.within-2x.md`. Unit suite 92 files / 1149 tests; lint, 11 typecheck passes and the docs gates clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr
… of them The previous revision fixed one silent failure and left three. Since this lands in 3.0.0 there is no reason to carry them, so the method now has one contract: it resolves `true` when the transport accepted the frame, and rejects otherwise. Newly reported: - **The connector's refusal.** `send()` returns `false` when the frame did not leave — the socket is not open, long-polling has no publication path or no `XMLHttpRequest`. That boolean was handed to the caller as a resolved value, which is this PR's own subject one layer further out. It now rejects with `JSSDK_PULL_SEND_REFUSED`. - **The same thing on the JSON-RPC branch**, which dropped the boolean and resolved `true` unconditionally. - **The publishing gate**, which threw a bare `Error` with no code to branch on. Now `JSSDK_PULL_PUBLISHING_DISABLED`. What the resolve still does NOT promise is delivery: `send()` hands the frame to the socket, and long-polling fires its request without awaiting the response. The contract is "the transport took it", and the JSDoc and the error-codes page both say so rather than leaving the reader to assume otherwise. Documentation, swept rather than spot-fixed: - `3.within-2x.md` is rewritten around what the caller has to DO. Four numbered steps with compiled examples: publish from the back end in an application; branch on the three codes, because only one is worth retrying; attach the `catch` at the call site in a timer, which is the case that can now take a process down; and stop reading the resolved value. - The Error Codes page gains both new codes, each saying what it replaced. - The Pull page states all three rejections and links to the migration note. - `b24jssdk-helpers/SKILL.md` gains a "the Pull client only RECEIVES" section with the `pull.application.event.add` example. It taught subscription only, so nothing it generated was broken — but nothing steered a model away from reaching for `sendMessage()` either. - Checked and unchanged: no example page and no other skill calls either send method, so there was nothing to correct there. Tests: three cases for the new rejections, each mutation-checked — ignoring a refused send reddens three, changing the publishing code reddens one. Unit suite 92 files / 1152 tests; lint, 11 typecheck passes and the docs gates clean. BREAKING CHANGE: `PullClient.sendMessage()` and `sendMessageToChannels()` reject with `JSSDK_PULL_PUBLIC_IDS_UNAVAILABLE`, `JSSDK_PULL_SEND_REFUSED` or `JSSDK_PULL_PUBLISHING_DISABLED` where they used to resolve, and resolve `true` after the transport accepts the frame rather than `undefined` or a boolean immediately. See `3.within-2x.md`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr
IgorShevchik
added a commit
that referenced
this pull request
Sep 23, 2026
…edentials that do not exist (#556) * fix(playground): show the error CODE in the Pull lab, not just the message The lab reported `error.message`, which is the `SdkError` description. After #555 there are three distinct ways a Pull send fails — `JSSDK_PULL_PUBLIC_IDS_UNAVAILABLE` (nobody to send to), `JSSDK_PULL_SEND_REFUSED` (the transport would not take the frame) and `JSSDK_PULL_PUBLISHING_DISABLED` (the portal forbids it) — and the code is what tells them apart and what a caller branches on. Two of the three read alike from the message alone. Check 9 now also says what the expected outcome IS in an application: a fail carrying `[JSSDK_PULL_PUBLIC_IDS_UNAVAILABLE]`, because `pull.channel.public.list` is not in the application REST surface. Without that line the honest result looks like a defect, and — since until 3.0.0 the same situation reported success and dropped the message in silence — seeing the code is precisely the evidence that the fix is in the build being run. Playground only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr * docs(playground): the playground needs no credentials in .env The Setup section said to fill in Bitrix24 credentials, but `.env.example` declares only `NUXT_ALLOWED_HOSTS` — the app authenticates through the Bitrix24 frame, so the portal supplies the auth at runtime. Following the instruction literally leaves you looking for a value that does not exist. Also says plainly that opening the Pull lab at localhost will not work, and why: `$initializeB24Frame()` has no parent portal outside the placement. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr --------- Co-authored-by: Shevchik Igor <noreply@anthropic.com>
IgorShevchik
added a commit
that referenced
this pull request
Sep 23, 2026
…557) * fix(playground): tell an encoded-and-sent frame apart from one never sent Check 9 reported `fail` with "accepted but nothing came back" whenever no echo arrived. That sentence described the pre-3.0.0 client, where `sendMessage()` resolved before the channel lookup had answered and an unsent message was indistinguishable from an undelivered one. Since #555 it cannot be: the call returns only after the batch has been encoded by the codec under test and the connector has accepted the frame, and every earlier failure throws with a code. So the page was reporting the one outcome that can no longer happen, and `encodePathExercised` said `false` on runs where the encoder had demonstrably run. Measured on a live push-server v4 portal, which is what surfaced this: the run produced no rejection at all. `PullClient` seeds the channel cache from `config.publicChannels`, returned there by `pull.config.get` — an ordinary application call — so `pull.channel.public.list` was never requested and had nothing to refuse. The batch was encoded, the socket took it, and the server did not redeliver it. A missing echo is therefore a push-server delivery fact, not a codec one: it is now a `warn` whose text says what did run, and `encodePathExercised` is tracked from the send itself rather than inferred from the verdict. The channel-manager JSDoc and the migration note said the rejection was the permanent answer for an application; both now cover the cached-channel case as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr * fix(playground): subscribe the Pull lab to client-published messages Check 9's echo was unroutable by construction, so the check timed out on every portal it was ever run against and the page blamed the push server. `subscribe()` defaults `type` to `SubscriptionType.Server`, and the lab took that default. `broadcastMessage` routes on the frame's own `extra.sender.type`: a message the BACK END published — which is how checks 4-8 send, through `pull.application.event.add` — arrives as `Server`, while one a CLIENT published through `sendMessage()` arrives as `Client` and is emitted to a different subscriber set entirely. `sendMessage()` is the only way to reach the encoder, so check 9 was the one check whose reply could never arrive. The lab now subscribes to both. That also corrects the diagnosis this branch was opened with. The previous commit read "no exception, no echo" as proof that the server had declined to redeliver an accepted frame. It was not proof of anything: the lab could not have observed the echo either way. What the run does establish is narrower and still worth having — the channel lookup was skipped because the recipient was already cached, so the batch was encoded and the socket took the frame. A missing echo does not acquit the encoder: the push server drops a frame it cannot parse or address in silence, which looks identical. Also in the lab: - check 9 now skips on JSON-RPC portals. Its only gate was `isPublishingEnabled()`, which is `version > 3` and therefore also true on push-server 5+, where `sendMessage()` never reaches either codec. The check would have reported an encode that did not happen. - `encodePathExercised` resets per run, and is set on a `JSSDK_PULL_SEND_REFUSED` failure too, since that is thrown after the encoder has already run. In the SDK, one hole behind the same claim: both JSON-RPC publish paths went through `this._jsonRpcAdapter?.…`, which resolves `undefined` before `init()` has assigned the adapter — nothing encoded, no frame, no error, which is the outcome 3.0.0 exists to remove. `sendMessageBatch` was worse: the optional chain fed `JSON.stringify(undefined)` to the connector, putting the string "undefined" on the wire and resolving `true`. Both now reject with `JSSDK_PULL_SEND_REFUSED`. Documentation carried the overstatement this branch set out to fix in five more places than it corrected — `sendMessage()`'s own JSDoc, the public Pull page, the error-codes row, the skill, and the playground README, which still described one outcome for a check that now has three. The contributing guide claimed the encode half had no route to a portal from an application; it does, and the guide's exit criterion is now split into a decode half that is met and an encode half that is not, because no encoded frame has been read back. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr --------- Co-authored-by: Shevchik Igor <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Running
/pull-lab(#554) against a live portal turned up a defect it was notlooking for:
PullClient.sendMessage()resolved as if the message had beenaccepted, nothing ever arrived, and nothing anywhere said why.
Four separate ways for a send to go nowhere quietly, found one at a time — the
last three by a five-reviewer panel over the first revision:
sendMessageBatchstarted the channel lookup and dropped the promise, sothe method resolved before the lookup had answered.
ChannelManager.getPublicIdscaught the portal's refusal and resolved anempty channel map — a valid input to the encoder, which then built a
message addressed to nobody.
send()returnsfalsewhen the frame does not leave, andthat boolean was passed to the caller as a resolved value — the same
defect one layer further out. Same on the JSON-RPC branch, which dropped the
boolean and resolved
trueunconditionally.Errorwith no code to branch on.What it does now
One contract: resolve
truewhen the transport accepted the frame, rejectotherwise.
JSSDK_PULL_PUBLIC_IDS_UNAVAILABLEsendMessage()only.JSSDK_PULL_SEND_REFUSEDJSSDK_PULL_PUBLISHING_DISABLEDtrueis not a delivery receipt, and the JSDoc and the Error Codes page sayso:
send()hands the frame to the socket, and long-polling fires its requestwithout awaiting the response.
The cause is not a fault
pull.channel.public.listis not part of the application REST surface.Bitrix24's documentation states an application's Pull client is receive-only
— the back end publishes with
pull.application.event.add. The SDK's own Pullpage already said exactly that; it just also exposed a method that could not
honour it.
The spec that pinned the old behaviour
An existing case asserted the empty resolve, because "a failed channel lookup
must not reject into the caller's pull loop". That path does not exist —
verified independently by two reviewers:
getPublicIdsis reached only fromsendMessageBatch, reached only from the two public methods, neither of whichhas an internal caller, and
ChannelManageris not exported from the packageindex. The case is inverted rather than deleted, with the reasoning recorded on
it, and its file header no longer asserts the behaviour it drops.
What the panel changed
The fix had a hole the tests could not see. A refusal reaches
ChannelManagerin two shapes — a thrownAjaxError, and a RESOLVEDnon-success
AjaxResulton theisSoftError()path. The rejection onlyhappened on the second because reading
.resultoffgetData()'sundefinedthrew a
TypeError: it worked by accident, and logged thatTypeErrorratherthan the portal's message — so on that path it still did not say why. Both
deliveries are handled explicitly now, the cause travels in
originalError, anda successful answer yielding no channel for any requested user rejects rather
than resolving an empty map.
The tests could not see it because they faked
actions.v2.call.makewholesale.The seam is now
vi.spyOn(httpClient.ajaxClient, 'post')astesting.mdspecifies, so the real
HttpV2.call()builds what the SDK would hand over.sendMessageToChannels()does not raise the lookup error, and two documentssaid it did. It carries
channelListand nouserList, so the lookup isnever reached. Corrected, and pinned by a case. The same short-circuit means
sendMessage()does not reject when every channel is cached either.The real-frames test was green on a real divergence. Its normaliser
coalesced every field with
??, erasing proto2 presence — the one property aproto2 codec gets wrong.
Sender.idis absent on the wire in every frame:protobuf.js reports it absent, the lite codec materialises an empty
Uint8Array. Deleting the lite codec's entire default initialiser also left1145 tests green. The comparison no longer defaults the scalars, the known
divergence has its own case, and the defaults are pinned by a synthetic frame in
the differential spec — no recorded frame can cover them, because every real
message carries all four fields.
Documentation
Swept rather than spot-fixed.
3.within-2x.mdis written around what the caller has to do: fournumbered steps with compiled examples — publish from the back end in an
application; branch on the three codes, because only one is worth retrying;
attach the
catchat the call site in a timer; stop reading the resolvedvalue.
b24jssdk-helpers/SKILL.mdgains a "the Pull client only RECEIVES"section with the
pull.application.event.addexample. It taught subscriptiononly, so nothing it generated was broken — but nothing steered a model away
from reaching for
sendMessage()either.send method.
The recorded-frame fixture
pull-protobuf.mdset the exit criterion for deleting the vendored protobuf.js:a
ResponseBatchrecorded from a live portal, committed as a fixture, decodedby both codecs. It is here, on the decode side — two frames, one small and
one whose body passes the three-byte length prefix. They agree.
The fixture carries its own
knownGapsandregeneratingfields. The gaps,stated rather than implied: decode-side only (encode is unreachable from an
application); no stats frame was ever emitted, so the
oneofrests on thedifferential suite; every message carries all four scalars, so the decode
defaults need a synthetic case; the bodies are ASCII, so multi-byte UTF-8
decoding is not exercised by real bytes at all.
One substitution, in place and equal in length: the portal hostname in
extra.server_name. Equal length is mandatory andregeneratingsays so — ashorter replacement invalidates four nested length prefixes and the frame stops
parsing.
Compatibility
Breaking, and marked as such. Two things a caller can be relying on:
The success path changed too:
sendMessage()used to resolveundefinedimmediately, effectively fire-and-forget. It now awaits the lookup and the send.
Step 3 of the migration note is the fix for the first; step 4 for the second.
Testing
publish-failure-is-reported.unit.spec.ts— 12 cases: both refusal deliveries,the cause, the message, the no-value-interpolation rule, the rejection path with
a lookup assertion, the success path with a
connector.sendspy,sendMessageToChannels(), a refused send, a missing connector, and thepublishing gate.
real-portal-frames.unit.spec.ts— 6 cases, including the pinned divergence anda body-length assertion that checks the decoded body rather than the frame.
Mutation-checked throughout, including the panel's own false-green
demonstrations: restoring the soft-error swallow, deleting the send, deleting
the codec defaults, ignoring a refused send and reading
createdas a varintall redden now.
Unit suite 92 files / 1152 tests;
lint,lint:md,lint:md-links, all 11typecheck passes,
docs-lint --strictanddocs:lint-linksclean.🤖 Generated with Claude Code
https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr