Skip to content

fix(client,talk): keep listen loops alive, real reqSeq for react - #239

Merged
EdamAme-x merged 4 commits into
evex-dev:mainfrom
frankekn:upstream/listen-catch
Sep 7, 2026
Merged

EdamAme-x merged 4 commits into
evex-dev:mainfrom
frankekn:upstream/listen-catch

Conversation

@frankekn

@frankekn frankekn commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Summary

Three fixes for the process that listens: two failures that kill it outright, and a reaction request that always sends sequence number 0. They are independent of each other and of the earlier pull requests from this fork; happy to split them if you would rather review them separately.

1. A failing listen loop takes the host process down

Client.listen starts one floating async IIFE per stream (packages/linejs/client/client.ts:136-177 for talk, :180-196 for square), and Polling starts the pusher the same way (packages/linejs/base/polling/mod.ts:179, :185):

if (opts.talk) {
	(async () => {
		for await (const event of polling.listenTalkEvents()) {
			this.emit("event", event);
			// ... await this.base.e2ee.decryptE2EEMessage(event.message) ...
		}
	})();           // not awaited, not caught
}

Nothing observes those promises. A transport error from the event stream, a decrypt failure on a single message, or a throw from one of the consumer's own listeners — emit calls them synchronously and does not guard them — reaches the runtime as

error: Uncaught (in promise) TypeError: ...

and terminates the process. initLegyPusher has the same problem from the other side: it rethrows when the first initializeConn fails (base/polling/mod.ts:160-164), and the two call sites do not await it, so that rejection has nowhere to go either. A bot whose whole job is to listen dies from one transient failure, and the code that asked for the stream never learns why.

Fix. Give each loop a .catch that reports on the log channel, the way base/push/conn.ts:137 already reports a failed connect: log("LegyPusherError", { error }). Only the loop that failed stops; the other loop, and the process, keep running. In Polling this lives in a small #startLegyPusher() so both listenTalkEvents and listenSquareEvents get it.

The report itself is wrapped in try {} catch {} for the same reason conn.ts guards its own: log fans out to user-supplied listeners, so an unguarded report would put back the unhandled rejection the handler exists to prevent.

One consequence worth naming: initLegyPusher already logs LegyPusherError_cannot_init before rethrowing a failed first connect, so that path now reports twice — the connect that failed, then the loop that ended. Both are true and the pair reads as the sequence it is; the alternative was inventing a third log type that existing consumers cannot anticipate.

2. The E2EE retry check throws inside its own catch

sendMessage decides whether to retry over E2EE by reading the error's code (packages/linejs/base/service/talk/mod.ts:172-183):

} catch (error) {
	if (
		error instanceof InternalError &&
		(error.data?.code.toString()).includes("E2EE") &&

InternalError.data defaults to {} (base/core/utils/error.ts:7) and only the two throw sites that carry a TalkException fill in a code. For every other InternalError — the client-closed guard at base/request/mod.ts:83, for one — code is undefined, .toString() throws from inside the handler that was inspecting the error, and the caller gets

TypeError: Cannot read properties of undefined (reading 'toString')

instead of the error that actually stopped the send.

Fix. Read it as String(error.data?.code ?? "").includes("E2EE"): absent becomes "", non-string codes still stringify, and the decision is unchanged for the E2EE codes it was written for.

3. react always sends reqSeq 0

TalkService.react hardcodes reqSeq: 0 (packages/linejs/base/service/talk/mod.ts:847), so every reaction sent in a session carries the same sequence number, while every other talk request draws one from getReqseq() (base/core/mod.ts:234), the monotonic per-service counter kept in storage.

Fix. Default the field to await this.client.getReqseq() — the same "talk" bucket sendMessage (:142) and the compact senders (:216, :240) use — and accept an optional reqSeq for callers that drive the sequence themselves. The change is additive: TalkMessage.react (client/features/message/talk.ts:106) is untouched and now simply sends a distinct number per reaction.

Testing

New packages/linejs/client/listen.test.ts, 4 cases against a stub polling source:

  1. A talk stream that throws after one event → listen() reports one LegyPusherError carrying the original error, the event delivered before the failure still reached the consumer, and no unhandled rejection is raised. On main it is, and Deno's default test sanitizers report that as a failure the same way the runtime reports it as a crash.
  2. The same for the square stream.
  3. A listener that throws inside emit → reported, process survives.
  4. A log listener that itself throws → still no unhandled rejection.

packages/linejs/base/polling/mod.test.ts, 2 added cases: listenTalkEvents with a pusher that cannot connect reports ["LegyPusherError_cannot_init", "LegyPusherError"] and still returns its stream; listenSquareEvents survives a throwing log listener.

New packages/linejs/base/service/talk/mod.test.ts, 5 cases: sendMessage rethrows a codeless InternalError unchanged (the TypeError above on main), still retries with E2EE when the code asks for it, still rethrows a non-E2EE code without retrying; react takes its sequence number from the talk bucket, and honours a caller-supplied one — both decoding the written thrift the way base/service/relation/mod.test.ts does.

cd packages/linejs && deno test -A — 286 passed, 0 failed (275 on main @ 8bd2f77). Repository-root deno test -A — 330 passed, 0 failed. deno fmt --check and deno check packages/linejs/client/mod.ts clean.

client/features/call/stun.test.ts needs a live UDP round trip to stun.l.google.com and failed once here on a dropped packet; it passes on a rerun and is untouched by this change.

`Client.listen` starts one floating async IIFE per stream and `Polling`
starts the pusher loop the same way, so nothing observes their
rejections. A transport error, a decrypt failure or a throw from one of
the consumer's own listeners -- `emit` calls them synchronously and does
not guard them -- reached the runtime as "Uncaught (in promise)" and
took the whole host process down. A bot that listens is exactly the
process that cannot afford that.

Report the error on the log channel instead, the way push/conn.ts
already reports a failed connect: `log("LegyPusherError", { error })`.
Only the loop that failed stops; the other loop, and the process, keep
running, and the consumer sees the failure rather than a silent exit.

The report itself is guarded, for the same reason conn.ts guards its
own: `log` fans out to user-supplied listeners, so letting one throw
would put back the unhandled rejection the handler exists to prevent.

`initLegyPusher` already logs `LegyPusherError_cannot_init` before it
rethrows a failed first connect, so that path now reports twice -- the
connect that failed, then the loop that ended. Both are true and the
pair reads as the sequence it is.

(cherry picked from commit 66baa6e)
`sendMessage` decides whether to retry over E2EE by reading
`error.data?.code.toString()`. `InternalError.data` defaults to `{}` and
only the two request throw sites that see a `TalkException` fill in a
code, so for every other failure -- the client-closed guard, anything
carrying a bare `data` -- `code` is undefined and `.toString()` threw a
TypeError from inside the handler that was inspecting the error.

The caller then got "Cannot read properties of undefined (reading
'toString')" instead of the error that actually stopped the send, which
is the one thing a catch block must never do.

Read the code through `String(error.data?.code ?? "")` instead: absent
becomes "", non-string codes still stringify, and the retry decision is
unchanged for the E2EE codes it was written for.

(cherry picked from commit fdbed1a)
`react` hardcoded `reqSeq: 0`, so every reaction sent in a session
carried the same sequence number. reqSeq is how the server tells one
request apart from a retry of the earlier one, and every other talk
request already draws it from `getReqseq()`, which keeps a monotonic
counter per service in storage.

Default the field to `await this.client.getReqseq()` -- the same "talk"
bucket sendMessage and sendChatChecked use -- and accept an optional
`reqSeq` for callers that drive the sequence themselves. Existing
callers, including TalkMessage.react, are unaffected apart from now
sending a distinct number per reaction.

(cherry picked from commit b26a97c)
Copilot AI lite review requested due to automatic review settings September 7, 2026 07:09

Copilot AI 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.

🟡 Changes recommended

Polling.initLegyPusher() can leave islisten stuck true on initial connect failure, preventing any subsequent pusher restart and leaving streams permanently empty after a transient failure.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR improves runtime resilience of long-lived listening/push loops in @evex/linejs/linejs by preventing unhandled promise rejections from crashing the host process, and fixes two TalkService request correctness issues (E2EE retry code inspection and reaction request sequencing). It also adds targeted Deno tests to validate the new behavior.

Changes:

  • Prevent unhandled rejections in Client.listen() loops by catching and reporting failures via base.log.
  • Prevent unhandled rejections from the Polling pusher startup by starting it through a guarded helper.
  • Fix TalkService.sendMessage() E2EE retry decision for InternalErrors without a code, and make TalkService.react() use a real request sequence number (with optional override), with new tests.
File summaries
File Description
packages/linejs/client/client.ts Adds guarded error reporting (.catch(...)) for floating listen loops to avoid process-killing unhandled rejections.
packages/linejs/client/listen.test.ts Adds tests proving listen loops report failures (including throwing listeners) without unhandled rejections.
packages/linejs/base/service/talk/mod.ts Fixes E2EE retry code inspection and makes react() use a monotonic reqSeq by default.
packages/linejs/base/service/talk/mod.test.ts Adds tests covering codeless InternalError behavior, E2EE retry, and react() reqSeq semantics.
packages/linejs/base/polling/mod.ts Starts the Legy pusher via a helper that catches/logs rejections instead of letting them go unobserved.
packages/linejs/base/polling/mod.test.ts Adds tests ensuring pusher init failures/log-listener throws don’t cause unhandled rejections.
Review details
  • Files reviewed: 6/6 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread packages/linejs/base/polling/mod.ts
@EdamAme-x

Copy link
Copy Markdown
Member

Reviewed the complete diff and adjacent polling/stream writer, event dispatch, E2EE retry, and sequence persistence paths. The original fixes are valid, but I reproduced five failing cases before applying follow-up commit 49ac26e:

  1. Initial connection failure left islisten=true, preventing restart (also identified by Copilot). Reset it in finally and preserve the original error even when logging throws.
  2. Startup failure left stream readers waiting forever. Both shared streams now receive the original terminal error; callers can explicitly listen again.
  3. A single synchronous listener/decrypt failure stopped subsequent delivery. Catch per-event errors inside Talk/Square loops; terminal stream errors remain caught outside.
  4. Concurrent first reactions all allocated reqSeq=0 because getReqseq initialization raced. Serialize allocation and persistence; five parallel reactions now encode 0,1,2,3,4.

Added 14 regression/boundary tests including failed persistence recovery, explicit reqSeq=0, lossless large message IDs, missing/null/numeric error codes, explicit E2EE retry limits, and a throwing logger.

Validation: repository deno test -A = 344 passed / 0 failed; Node 24 transport tests = 2 passed; linejs and linejs-types JSR dry-runs passed; touched TypeScript formatted and git diff --check passed. Waiting for CI on 49ac26e before merge and v3.4.1 release.

Scope: no automatic retry of terminal initial connection errors; synchronous handler errors skip the affected event. Async event/log handlers must handle their own promise rejections (documented). No live LINE account verification performed.

@EdamAme-x
EdamAme-x merged commit 7dd9582 into evex-dev:main Sep 7, 2026
2 checks passed
@frankekn

frankekn commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the follow-up commit, and for merging.

All four cases are real. The stuck islisten was a known consequence of the change I opened — the loop used to take the process with it, so the flag never mattered — and I agree it belongs in the same PR rather than a later one; clearing it in a finally and terminating both shared streams with the original error is the better shape. The per-event catch in the listen loops and the serialized getReqseq are both improvements on what I sent.

Verified the merged tree here: repository root deno test -A 344 passed / 0 failed, packages/linejs 300 / 0, deno fmt --check and deno check packages/linejs/client/mod.ts clean.

@frankekn
frankekn deleted the upstream/listen-catch branch September 7, 2026 07:26
EdamAme-x pushed a commit that referenced this pull request Oct 10, 2026
…them unhandled

`_OnPingCallback` fires `talk.noop()` every third ping (every 90 s) without
a catch, and Conn calls `_OnPushResponse` without awaiting it, so a request
that hits `config.timeout` rejects with an `AbortSignal.timeout`
TimeoutError that nobody observes. Node and Deno treat that as an
unhandled rejection and terminate the host process.

Catch both and report them through `ConnManager.log` as `noop failed` /
`fetchMyEvents failed`, keeping the connection alive. As in #232 and #239,
the report is wrapped in try/catch because `log` fans out to user-supplied
listeners and a throwing listener would otherwise bring the unhandled
rejection back.
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.

3 participants