Skip to content

fix(pull)!: report every way a send can fail, instead of hiding four of them - #555

Merged
IgorShevchik merged 3 commits into
mainfrom
claude/pull-publish-silent-failure
Sep 22, 2026
Merged

IgorShevchik merged 3 commits into
mainfrom
claude/pull-publish-silent-failure

Conversation

@IgorShevchik

@IgorShevchik IgorShevchik commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator

Why

Running /pull-lab (#554) against a live portal turned up a defect it was not
looking for: PullClient.sendMessage() resolved as if the message had been
accepted, 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:

  1. sendMessageBatch started the channel lookup and dropped the promise, so
    the method resolved before the lookup had answered.
  2. ChannelManager.getPublicIds caught the portal's refusal and resolved an
    empty channel map
    — a valid input to the encoder, which then built a
    message addressed to nobody.
  3. The connector's send() returns false when the frame does not leave, and
    that 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 true unconditionally.
  4. The publishing gate threw a bare Error with no code to branch on.

What it does now

One contract: resolve true when the transport accepted the frame, reject
otherwise.

Code When
JSSDK_PULL_PUBLIC_IDS_UNAVAILABLE The recipients' channels could not be resolved. sendMessage() only.
JSSDK_PULL_SEND_REFUSED The transport would not take the frame.
JSSDK_PULL_PUBLISHING_DISABLED The portal has not enabled client publishing.

true is not a delivery receipt, and the JSDoc and the Error Codes page say
so: send() hands the frame to the socket, and long-polling fires its request
without awaiting the response.

The cause is not a fault

pull.channel.public.list is 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 Pull
page 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: getPublicIds is reached only from
sendMessageBatch, reached only from the two public methods, neither of which
has an internal caller, and ChannelManager is not exported from the package
index. 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
ChannelManager in two shapes — a thrown AjaxError, and a RESOLVED
non-success AjaxResult on the isSoftError() path. The rejection only
happened on the second because reading .result off getData()'s undefined
threw a TypeError: it worked by accident, and logged that TypeError rather
than 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, and
a 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.make wholesale.
The seam is now vi.spyOn(httpClient.ajaxClient, 'post') as testing.md
specifies, so the real HttpV2.call() builds what the SDK would hand over.

sendMessageToChannels() does not raise the lookup error, and two documents
said it did.
It carries channelList and no userList, so the lookup is
never 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 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 entire 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.

Documentation

Swept rather than spot-fixed.

  • 3.within-2x.md is written 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; stop reading the resolved
    value.
  • Error Codes 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.

The recorded-frame fixture

pull-protobuf.md set the exit criterion for deleting the vendored protobuf.js:
a ResponseBatch recorded from a live portal, committed as a fixture, decoded
by 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 knownGaps and regenerating fields. The gaps,
stated rather than implied: decode-side only (encode is unreachable from an
application); no stats frame was ever emitted, so the oneof rests on the
differential 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 and regenerating says so — a
shorter 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:

A loop with no catch can now take the process down. A heartbeat calling
sendMessage() on a timer without handling the promise, on a portal where the
lookup fails transiently, used to lose that one message and carry on. Now
the call rejects, and an unhandled rejection terminates the process by default
in Node 15 and later. For that caller the old behaviour was better — it was
not better for anyone who needed to know their messages were not arriving.

The success path changed too: sendMessage() used to resolve undefined
immediately, 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.send spy,
sendMessageToChannels(), a refused send, a missing connector, and the
publishing gate.

real-portal-frames.unit.spec.ts — 6 cases, including the pinned divergence and
a 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 created as a varint
all redden now.

Unit suite 92 files / 1152 tests; lint, lint:md, lint:md-links, all 11
typecheck passes, docs-lint --strict and docs:lint-links clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01F22e2ft66y7nuBJjzdThBr

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
IgorShevchik force-pushed the claude/pull-publish-silent-failure branch from 5ca227f to b51126c Compare September 22, 2026 10:42
… 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
@IgorShevchik IgorShevchik changed the title fix(pull): report a failed publish instead of reporting success, and land the recorded-frame fixture fix(pull)!: sendMessage() rejects instead of reporting a publish that never happened Sep 22, 2026
… 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 IgorShevchik changed the title fix(pull)!: sendMessage() rejects instead of reporting a publish that never happened fix(pull)!: report every way a send can fail, instead of hiding four of them Sep 22, 2026
@IgorShevchik
IgorShevchik merged commit 2fb18a9 into main Sep 22, 2026
10 checks passed
@IgorShevchik
IgorShevchik deleted the claude/pull-publish-silent-failure branch September 23, 2026 04:08
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>
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.

2 participants