Skip to content

plugin: fix stdio framing that crashed lightningd on concatenated reads - #81

Merged
vincenzopalazzo merged 3 commits into
mainfrom
fix/plugin-stdio-framing
May 7, 2026
Merged

vincenzopalazzo merged 3 commits into
mainfrom
fix/plugin-stdio-framing

Conversation

@vincenzopalazzo

Copy link
Copy Markdown
Owner

Summary

Fixes a JSON-RPC framing bug in the plugin runtime that could panic the
plugin and, since plugins are typically marked important, take down
lightningd with it. Discovered while debugging
vincenzopalazzo/cln-offers#35,
which fixes the cln-offers-side trigger but acknowledges the framework
fragility as separate work.

The crash signature in production logs was:

BROKEN plugin-cln4go-plugin: invalid character '{' after top-level value
BROKEN plugin-cln4go-plugin: Plugin marked as important, shutting down lightningd!

Root cause

Three compounding issues in plugin/plugin.go:

  1. stdin reading. The loop detected message boundaries with
    bytesResp1 < buffSize. This is unreliable on streams: if two JSON-RPC
    messages get delivered in a single Read() (which happens when the OS
    pipe buffer fills, or when log notifications race a request), they end
    up concatenated in the same buffer, json.Unmarshal fails with
    invalid character '{' after top-level value, and the framework
    panics.
  2. stdout writing. Responses were not terminated with the \n\n
    delimiter that the CLN plugin protocol uses to separate messages.
    Combined with cln: implement unix client to talk with core lightning #1 on the reading side, peers had no reliable way to
    split messages either.
  3. Log() writer. It created a fresh bufio.NewWriter(os.Stdout) on
    every call, separate from the writer the response loop used.
    Concurrent calls and even sequential calls across the two writers
    could interleave bytes on stdout.

Changes

  • Replace the read loop with a bufio.Scanner whose split function emits
    each chunk between two consecutive \n\n delimiters as a discrete
    token. Each Scan() returns exactly one JSON-RPC message regardless of
    how the bytes were delivered.
  • Add a shared *bufio.Writer field on Plugin[T] guarded by
    sync.Mutex. Every outbound message (responses and log notifications)
    is funneled through one writeMessage helper that appends the \n\n
    delimiter and flushes under the lock.
  • Parse failures now log via Log("broken", ...) and continue, instead
    of panicking. A single corrupt frame can no longer terminate the
    plugin.
  • Fix the invalid CLN log level "warn" -> "unusual" in the
    non-JSONRPCError fallback path (valid levels are
    io|debug|info|unusual|broken).
  • Extract the inner loop into a run(io.Reader, io.Writer) method so
    the framing behaviour is unit-testable without touching real stdio.

Test plan

plugin/plugin_io_test.go adds five tests, all passing under -race:

  • TestRunHandlesConcatenatedRequests — two requests in one
    strings.NewReader produce two correctly-id'd responses
    (reproduces the original crash without the fix).
  • TestRunHandlesSplitRequest — a chunkedReader that returns the
    message in two Read() calls is reassembled correctly.
  • TestRunRecoversFromMalformedRequest — a garbage frame followed
    by a valid one results in one response and a failed to parse request log notification (no panic).
  • TestWriteMessageIncludesDelimiter — every emitted message ends
    with \n\n and contains no internal \n\n.
  • TestLogAndResponseDoNotInterleave — 16 goroutines × 32 messages
    via writeMessage produce 512 cleanly-framed JSON objects with no
    interleaving.
$ cd plugin && go test -race -run 'TestRun|TestWriteMessage|TestLog' ./...
ok  	github.com/vincenzopalazzo/cln4go/plugin	1.319s

golangci-lint run and gofmt -l are both clean for the plugin module.
The pre-existing TestCallFistMethod / TestOptionValueExist integration
tests still require CLN_UNIX_SOCKET to be set — unchanged by this PR.

🤖 Generated with Claude Code

@netlify

netlify Bot commented May 7, 2026 •

Copy link
Copy Markdown

✅ Deploy Preview for cln4go canceled.

Name Link
🔨 Latest commit c318e29
🔍 Latest deploy log https://app.netlify.com/projects/cln4go/deploys/69fc72211f618b0008c2e04c

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 439acc9bec

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread plugin/plugin.go Outdated

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b1f448e192

ℹ️ About Codex in GitHub

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

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

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

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

Comment thread plugin/plugin.go Outdated
vincenzopalazzo added a commit that referenced this pull request May 7, 2026
Two follow-ups from the Codex review on PR #81:

1. The bufio.Scanner reader had a 16 MB frame-size cap; any single
   JSON-RPC message larger than that returned bufio.ErrTooLong and
   terminated the read loop, dropping every subsequent request.
   The previous (broken) implementation had no cap. Replace the
   scanner with a bufio.Reader + ReadBytes('\n') loop that
   accumulates lines until the "\n\n" delimiter is seen, with no
   fixed cap — memory is bounded only by the largest single message.

2. writeMessage was void: when encoding/json could not marshal a
   response (e.g. result contained a channel, function, or NaN/Inf
   float), the failure was traced via the optional tracer and the
   request id went unanswered, making lightningd hang. Make
   writeMessage return its first error, and have dispatchRequest
   fall back to a minimal JSON-RPC error response (code -32603,
   "Internal error" per the JSON-RPC 2.0 spec) so the request id is
   always answered.

Tests:

- TestRunHandlesLargeFrame parses a 1 MiB params payload — far above
  the previous 16 MB cap is now uncapped, but this size also exceeds
  bufio.Scanner's 64 KB default buffer, so it would have failed under
  any earlier scanner-based variant.
- TestDispatchSendsErrorOnUnencodableResponse registers an RPC that
  returns a function value, drives one request through run(), and
  asserts that the framework answers with a -32603 error response
  carrying the original id rather than dropping the reply.

All five pre-existing tests still pass under -race.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
vincenzopalazzo and others added 3 commits May 7, 2026 13:05
The framework's Start() loop was vulnerable to a JSON-RPC framing bug
that could panic the plugin and, since plugins are typically marked
important, take down lightningd with it.

Three issues compounded:

1. The stdin loop detected message boundaries with the heuristic
   `bytesResp1 < buffSize`, which is unreliable on streams. When two
   JSON-RPC messages happened to be delivered in a single Read() the
   loop concatenated them into one buffer, json.Unmarshal failed
   with "invalid character '{' after top-level value", and the
   framework panicked.

2. Responses written to stdout were not terminated with the "\n\n"
   delimiter the CLN plugin protocol uses. Combined with #1 on the
   reading side, peers had no reliable way to split messages either.

3. Log() created a fresh bufio.NewWriter(os.Stdout) on every call,
   completely separate from the writer used by the response loop.
   Concurrent calls (and even sequential ones across the two
   writers) could interleave bytes on stdout.

Replace the read loop with a bufio.Reader.ReadBytes loop that
accumulates lines until the "\n\n" delimiter, with no fixed size cap
- memory is bounded only by the largest single message in the
stream. Funnel every outbound message - responses and log
notifications - through a single shared bufio.Writer guarded by
sync.Mutex, with the "\n\n" delimiter appended after each one.
writeMessage returns the first encode/write/flush error, and
dispatchRequest falls back to a minimal -32603 ("Internal error" per
JSON-RPC 2.0) response when the original cannot be serialized, so
the request id is always answered. Parse failures now log and
continue rather than panic, so a single corrupt frame can no longer
terminate the plugin.

Also fix the invalid CLN log level "warn" -> "unusual" used when an
RPC method returns a non-JSONRPCError.

Tests cover concatenated reads, fragmented reads, malformed-frame
recovery, the delimiter contract, concurrent writes from many
goroutines (race-detector clean), 1 MiB frames (no cap), and the
encode-failure fallback.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The mock-server handlers in client/unix_unit_test.go intentionally
ignore I/O errors — the test cases assert the *client* behaviour and
the server side just needs to drain the request, optionally write a
fixed response, and close. errcheck flagged 13 such call sites,
breaking the lint job in CI.

Assign the return values to `_` to make the intent explicit and keep
the linter quiet, without changing test behaviour.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The Integration testing workflow had zero historical runs because
multiple deprecated dependencies silently prevented it from
registering:

- actions/checkout@v2 and actions/upload-artifact@v2 were retired
  by GitHub Actions and silently no longer trigger workflows that
  reference them.
- 'docker-compose' (with hyphen) was removed from the ubuntu-latest
  runner; only 'docker compose' (the v2 plugin) is available.
- The integration container pinned Go 1.18.4, which predates the
  'toolchain' directive added to go.work. The current go.work
  specifies 'toolchain go1.24.2' so the test entrypoint failed
  with: "reading go.work: ... unknown directive: toolchain".

Bump the action versions to v4, switch to 'docker compose', match
the Go install to the toolchain directive, and add workflow_dispatch
so the integration suite can be triggered manually from the Actions
tab or via 'gh workflow run'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@vincenzopalazzo
vincenzopalazzo force-pushed the fix/plugin-stdio-framing branch from 3ceae84 to c318e29 Compare May 7, 2026 11:06
@vincenzopalazzo
vincenzopalazzo merged commit ded6563 into main May 7, 2026
10 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant