Serve WebSockets from the backend, and rebuild the CI screenshot server on it - #5880
shai-almog wants to merge 12 commits into
Conversation
…er on it The server-side backend could not serve WebSockets, and could not be made to from outside: HttpServer had no upgrade path, Request exposes no descriptor, drop(fd) owns every close, and a Response can only describe a status, a body or a file. So this is built in the way serveHttp2 was, rather than bolted on. An endpoint implements com.codename1.backend.WebSocket and is registered per path on HttpServer or Backend.Builder. Sessions carry the usual callbacks, PING is answered before the endpoint is told, and a session may be written to from any thread so a broadcast is possible. What the frame layer refuses matters more than what it accepts, and each of these is a case no conformant client produces: a non-minimal length (one value with two spellings in front of a proxy is a smuggling primitive), an unmasked client frame, a reserved bit with nothing negotiated, a fragmented or oversized control frame, a continuation with no message open, and a close code a peer may not send -- 1004 included, which a range check over 1000..1011 lets through. Text is validated as UTF-8 incrementally, so a message that goes wrong in its first fragment fails there rather than after however much more the peer sends. Three things this had to avoid rather than participate in: - The borrowed read buffer. HTTP parses in place inside a per-host-thread array, which is free for a request and wrong for a websocket, since a websocket stops between every message by design. The upgrade copies out whatever the client pipelined behind its handshake -- a browser routinely puts its first frame in the same packet -- releases the borrow, and owns its buffer from then on. - The request deadline. sweepDeadlines sheds anything idle past CN1_HTTP_TIMEOUT_MS, which is aimed at a client that began a request and stopped, and is indistinguishable from a websocket doing its job. Sessions get CN1_WS_IDLE_TIMEOUT_MS instead. - The drain accounting. An open session is a connection, not a request in flight, so it gives back the activeRequests count serve() raised for it. Otherwise stop() waits out its whole window for every silent peer, every time. The test is the screenshot transport, used for real. scripts/lib/cn1ss.sh now starts the backend server, so every on-device UI leg -- iOS, watchOS, tvOS, macOS, Catalyst, Android, Java SE and the browser -- points four independent real client stacks at it on every run, sending masked, fragmented, multi-hundred-kilobyte binary messages under ACK-paced flow control. Replaying a full device conversation against the old server and the new one gives identical ACK/NACK text, identical CN1SS:INFO/CN1SS:WARN lines and identical files. CN1SS_WS_SERVER picks the arm. The JavaScript leg runs the translated binary, because it is the only leg that is Linux, already builds ParparVM, is a single job rather than a matrix, and talks to a client that is not one of ours; every other leg runs the same Java on a JVM. Not with CN1_BACKEND_HTTPS=0, which looks free and deletes cn1_backend_crypto.c -- and a native with no C symbol takes its Java method with it, so Crypto.sha1 would vanish and every handshake would compute the wrong accept. scripts/common/java/Cn1ssScreenshotServer.java stays. Native Windows has no backend arm, and CleanTargetIntegrationTest and scripts/windows/run-hello.bat compile and run that file directly. The UI workflows now watch vm/backend, without which a websocket regression would break the screenshot transport on every leg while triggering none of them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A websocket route had to be registered by hand, while an HTTP route did not. @WebSocketMapping closes that: the build finds the endpoint and registers it in the generated entry point, so a websocket needs no more start-up code than a @RestController does. On the TYPE rather than on a method, and that is the one real design decision here. A @GetMapping marks a call -- one request in, one response out, and the method signature is the whole contract. A websocket is a connection, and its contract is seven callbacks sharing per-connection state, which is an object. An annotation on a method would mean either that the method runs per connection, which is a factory pretending to be a handler, or that it runs once and the annotation is on the wrong element. Everything else is what @GetMapping already does: the path is relative to a class-level @RequestMapping, a constructor taking a DataSource or an EntityManager is injected through the same requireX check, and two endpoints claiming one path is a build error rather than something scan order settles. Two things the tests caught that reasoning did not: - finish() took controllers.values().iterator().next() unguarded, so a module with only websocket endpoints threw NoSuchElementException. The entry point package now comes from whichever kind of class is present. - A path with no leading slash is normalised rather than refused, because @GetMapping is lenient about it and the annotation set is Spring's on purpose. A class-level @RequestMapping with no slash is still refused: that one cannot be repaired without guessing. Registration is emitted in path order, so the generated source is byte-identical between builds rather than following the scan. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Compared 12 screenshots: 12 matched. |
|
Developer Guide build artifacts are available for download from this workflow run:
Developer Guide quality checks: |
Native fidelity (Android, Material 3)54 pairs compared -- median 95.6%, worst 91.3% ( Distribution --
Geometry vs native (bbox offset / size ratio / center offset / corner radius) -- gated separately from the visual score
Side-by-side comparisons (worst first)
|
|
Compared 172 screenshots: 172 matched. Benchmark ResultsDetailed Performance Metrics
|
Most of RFC 6455 is about frames a conformant client never sends, so a server can be wrong in ways nothing it talks to will reveal. ws-conformance.sh drives the Autobahn fuzzing client at an echo endpoint and holds the result to two rules. Every case must be strictly green. NON-STRICT is a failure here, not a pass with a note: it means RFC-legal but lenient, and leniency in a frame parser is the whole bug class this exists to close. And the set of cases that RAN must equal a committed manifest. Without that, a spec edit, an image bump or a server that dies on case 3.2 silently shrinks the suite to the cases it happens to pass, and the job still reports green. The manifest records COVERAGE, not permitted failures -- there is no per-case tolerance anywhere in check-autobahn.py, and growing it is a diff somebody reads. Result on the Java SE arm: 301 cases, all strictly green, first run. Sections 12 and 13 are permessage-deflate and are EXCLUDED rather than tolerated as UNIMPLEMENTED. Tolerating that verdict would also tolerate it for a case that used to work, which is the regression this suite is for. Deleting the exclusion when deflate lands grows the manifest by 216 entries. The gate was checked against three mutations. Accepting any close code in 1000..1011 fails 7.9.3-7.9.5 with WRONG CODE; a manifest entry that did not run fails; a NON-STRICT verdict fails. A fourth is worth recording because it did NOT fail: accepting unmasked client frames passes Autobahn, because its client always masks. That rule is covered by WebSocketServerTest instead, which is the reason both layers exist. Three things the harness had to learn, none of which CI would have shown: - `--network host` means the host's network on Linux and the VM's on macOS, where it removes the host.containers.internal alias that is the only way in. The run then writes no report and exits 0. - An installed docker binary whose daemon is down is on PATH exactly like a working one, and `docker run` fails with 125 -- which under set -e ends the script right after it says it is starting, and reads as a hang. The runtime is probed with `info` now. - macOS ships bash 3.2, where an empty array expanded under `set -u` aborts the script. CI runs bash 5 and would never have shown it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Compared 172 screenshots: 172 matched. Benchmark ResultsDetailed Performance Metrics
|
|
Compared 157 screenshots: 157 matched. Native Android coverage
✅ Native Android screenshot tests passed. Native Android coverage
Benchmark ResultsDetailed Performance Metrics
|
Cloudflare Preview
|
The guide's prose gates are whole-corpus and warnings-are-errors, and the chapter was clean at 0/0/0 before this branch, so every one of these is mine. Two I could not run locally at first and so did not catch before pushing: - The paragraph-capitalisation check refuses a paragraph whose first prose word is lowercase, and "and a route points at one:" is one. - LanguageTool flagged six: "serialises" mixes variants with the eleven "serialize"s already in the chapter; "backpressure" is two words to its dictionary; and four prose uses of "websocket" want the product's own capitalisation. The code spellings -- the .websocket(...) call, the include tags -- are left exactly as they are, because they are identifiers. Running it locally afterwards found three more the CI report had not reached: a comma before "so" introducing a dependent clause, and "Testsuite" and "Podman", both product names, which are what languagetool-accept.txt is for. The guide renders and passes every gate: vale 0/0/0 over 124 files, paragraph capitalisation 0 issues, LanguageTool 0 matches, structure, xrefs, links, snippets, code blocks, API names and image alt text all clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
✅ Continuous Quality ReportTest & Coverage
Static Analysis
Generated automatically by the PR CI workflow. |
|
@codex review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7f3a2e218a
ℹ️ 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".
BackendWebSocketIntegrationTest builds the real binary through build.sh and drives it over a raw socket. maven/backend covers the same protocol on a JVM and that is not the same test: the Java SE arm is always pool mode, reaches no native, and -- the part that matters most here -- hands out descriptor numbers monotonically and never recycles one, so every use-after-close bug in the upgrade and teardown paths is structurally invisible there. Six tests, including descriptor reuse across twelve connections and a session that parks between messages, all green against the translated binary. Running the Autobahn suite against a real server found two things no unit test would have. The deferred-close sweep blocked the reactor thread. retire() waits up to 100ms for a writer to leave, which is right when drop() calls it from the connection's own thread and wrong from the sweep, which runs on the thread that also accepts. isQuiescent() is the non-blocking form, and the sweep uses it. And a websocket pins a pool worker for the life of its connection, so workerCount is the ceiling on concurrent connections in pool mode -- which is the mode the Java SE arm and every TLS server run in. Past that ceiling the failure is silent and actively misleading: the process is healthy, the listener is bound, kill -0 says it is alive, and connections are refused because no worker ever comes back to accept them. Measured: an eight-worker server reached case 9.4.4 and refused everything after it, and finding that from the outside took a bisect. The server now logs once when websockets hold half the pool, the conformance harness provisions workers for the suite it runs, and the guide says what the ceiling is. Result: 301 cases, strictly green, on BOTH arms. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Compared 172 screenshots: 172 matched. |
|
Compared 172 screenshots: 172 matched. |
|
Compared 172 screenshots: 172 matched. Benchmark ResultsDetailed Performance Metrics
|
…re is a fallback CI caught both of these, and the second is the more important one. build.sh runs generate-contract.sh whenever contract/ exists, which needs codenameone-core, codenameone-backend and the CN1 Maven plugin installed in the repo-local .m2-repo. The JavaScript leg builds only the parparvm module, so the native screenshot server could not be built there at all: "codenameone-core is not in .m2-repo", no binary. CN1_BACKEND_STANDALONE_DEMO is for a demo that needs neither the contract nor demo/common. It has to skip BOTH, and that is not a convenience -- demo/common's GreeterService is written against the contract's Pet, so dropping the contract while still compiling common fails at javac with seventeen missing symbols. That is how this flag was wrong the first time I wrote it, and the A/B that caught it needed gen/ moved aside: with a populated gen/ the contract step returns early and the bug is invisible. Verified from a fresh-checkout state against an empty .m2-repo: without the flag exit 1 with the CI message, with it a 2.9MB binary that passes its own self-check. petserver, which does use the contract and demo/common, still builds. The second fix is the one that cost eleven minutes of CI. When the server failed to start, three runner scripts logged "relying on base64 fallback" and CARRIED ON. There is no base64 fallback -- Cn1ssDeviceRunnerHelper says so in as many words -- so the suite ran, delivered nothing, and failed on a count gate long afterwards with no mention of the transport. They exit 6 now, the way the watch, tv and fidelity legs already did, and say why. Both arms still pass conformance: 301 cases, strictly green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Compared 154 screenshots: 154 matched. Benchmark Results
Detailed Performance Metrics
|
✅ ByteCodeTranslator Quality ReportTest & Coverage
Benchmark Results
Static Analysis
Generated automatically by the PR CI workflow. |
Native fidelity (iOS Modern, Metal)68 pairs compared -- median 95.0%, worst 83.5% ( Distribution --
Geometry vs native (bbox offset / size ratio / center offset / corner radius) -- gated separately from the visual score
Side-by-side comparisons (worst first)
|
The browser screenshot leg delivered 31 of 181 images, with no error on either side, the suite reporting completion, and the server reporting 31 written. Every local test passed. advance() arms SOCKET_TIMEOUT_MILLIS on the descriptor at EVERY park. That is right for a half-sent request -- without it a virtual thread parks for ever -- and it is fatal for a websocket, which parks between messages by design and looks exactly like a client that began a request and stopped. The upgrade set the websocket's own allowance and the first park overwrote it, so any connection quiet for more than fifteen seconds was closed by sweepDeadlines while both peers still believed it was open. Fixed the way the descriptor's other per-host state already works: VtHost gains webSocketByFd, grown by ensureCapacity and cleared by forget() and by setHandle(fd, 0) -- the second because a recycled descriptor number must not inherit a websocket's idle allowance for the HTTP connection that gets it next. advance() reads the flag where it arms the clock. Invisible to everything that was testing this. The Java SE arm is pool mode and never goes through advance(). The Autobahn suite has no idle gaps anywhere near fifteen seconds. Both unit and conformance suites passed throughout. So the test is the shape of the bug rather than the shape of the code: deliver, go quiet for longer than the request timeout, deliver again. It runs on both arms, and on the translated one CN1_HTTP_TIMEOUT_MS is lowered to 1500 so the wait is six seconds and the websocket allowance it must not inherit is four times that. Reverting the one-line fix fails it with "no answer to idleafter". Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Compared 166 screenshots: 166 matched. Benchmark Results
Detailed Performance Metrics
|
|
Compared 193 screenshots: 193 matched. |
|
Compared 149 screenshots: 149 matched. Benchmark Results
Build and Run Timing
Detailed Performance Metrics
|
|
Compared 150 screenshots: 150 matched. |
|
Compared 223 screenshots: 223 matched. |
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: c6ff5dea88
ℹ️ 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".
Eighteen from codex, and I checked the two P1 premises by running them rather than by reading. A websocket-only builder could not start. Backend counted HTTP routers alone and threw "This server has no handlers" -- so the module a @WebSocketMapping-only project generates compiled, started, and died before binding a port. My processor test asserted the emitted SOURCE and never ran it, which is why it saw nothing. Websocket routes now count as handlers and an HTTP 404 fallback goes in. The pooled arm closed idle websockets at the HTTP timeout. The worker keeps the descriptor blocking inside runWebSocket, so pooledDeadlines is never consulted while fill() waits -- what times the read out is SO_RCVTIMEO, installed at accept as CN1_HTTP_TIMEOUT_MS. Measured with a 2s HTTP timeout and a 5s idle: gone, and gone again with CN1_WS_IDLE_TIMEOUT_MS=0, which is documented as "never". This is the pooled twin of the bug CI found on the virtual-thread arm, and my own idle test missed it by waiting 3s against a 15s default -- calibrated to pass rather than to probe. The rest, each real: - routes are installed before the listener accepts, not after start() returns - a refused upgrade drops instead of parsing what the client pipelined behind it, which otherwise runs a request the refusal promised not to serve - deferred closes release the TLS session, which the early return was skipping - onOpen throwing ends the session, as onText and onBinary already do - fail() and a failed read report through onError, which the contract promises - no data frames once a Close has been sent - outbound close codes are validated, so an endpoint cannot ask for 1006 - closeForShutdown records 1001 instead of leaving onClose to report 1006 - shutdown goodbyes are bounded and concurrent rather than serial and blocking - stop() from a websocket callback discounts its own turn - getSubprotocols() throwing is handled like the router callback - reassembly memory is reserved against a process-wide ceiling - the processor accepts an inherited WebSocket and refuses a query string in a mapped path, which routing can never match - the native cache key covers the translator and the boot classpath One thing worth recording: the bounded-shutdown fix used CountDownLatch, the Java SE arm compiled it, and only the translated build objected -- the server-safe class library has java.util.concurrent.atomic and no CountDownLatch. An AtomicInteger and a bounded poll instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
I had every UI screenshot leg -- iOS, watchOS, tvOS, macOS, Catalyst, Android, the car leg, the browser and both fidelity legs -- trigger on vm/backend/src, impl, native and build.sh. That is wasteful and it is not what the risk needs. The transport is a backend application, so a websocket regression genuinely can break every screenshot run. But the websocket code is already covered three ways that all trigger on vm/**: maven/backend's unit tests, vm/tests against the translated binary, and the Autobahn conformance job. A change to the ORM, the database layer, the HTTP parser or a native has no path to this transport at all, and firing ten macOS and emulator jobs for one is a large bill for nothing. What is left is the pair of files nothing else exercises: the server application itself and the script that launches it. Those two can break the transport without any other job noticing, which was the actual gap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
CodenameOne/vm/backend/src/com/codename1/backend/HttpServer.java
Lines 2235 to 2236 in 091a255
The follow-up's new SERVING_WS marker is still bypassed because this early return runs whenever SERVING_FD is absent, and WebSocket callbacks never set SERVING_FD—only ordinary HTTP/HTTP2 handlers do. Consequently an endpoint that calls server.stop() from onText, onBinary, onPing, or onPong still waits the full drain and release windows for its own webSocketTurns count. Consult SERVING_WS before this return or track the WebSocket descriptor separately.
ℹ️ 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".
Native fidelity (ios-27-metal)68 pairs compared -- median 94.5%, worst 68.8% ( Distribution --
Geometry vs native (bbox offset / size ratio / center offset / corner radius) -- gated separately from the visual score
Side-by-side comparisons (worst first)
|
Eight findings, and five of them are consequences of the previous round rather than of the original work. Fair: a fix is code too. My "read timeout" was not one. ServerSocket.setTimeout sets SO_RCVTIMEO AND SO_SNDTIMEO -- the native says so in a comment, and the Java SE arm reads one map for both -- so giving a websocket a five-minute read allowance also gave it a five-minute SEND allowance, and CN1_WS_IDLE_TIMEOUT_MS=0 removed the send bound altogether. A peer that stopped reading could then block a broadcast thread for ever, which is the exact failure SO_SNDTIMEO was added for. There is now a setReceiveTimeout on both arms, with its own native, and the send deadline stays where accept left it. My shutdown fix started one thread per session. On the translated runtime that is a pthread with a 16MB stack that ABORTS THE PROCESS if creation fails, against a default ceiling of 4096 connections -- a graceful shutdown that kills the process is worse than an abrupt one. A fixed small fleet walks the sessions through a shared cursor instead, still bounded by the same deadline. My shutdown retirement ignored retire()'s answer, so a descriptor with a writer still inside it was closed anyway -- the cross-connection corruption the deferred close exists to prevent, and external writers are not in workOutstanding() so stop() can return while that write is in flight. Failed retirements defer now, and the sweep skips them. My `closing` guard was checked before the lock, so a sender could pass it, pause, and write a data frame after the Close. Rechecked under the lock. And websocket routing used the raw target while every HTTP route matches the CANONICAL path, so /ch%61t missed /chat and fell through to a catch-all or a 404 -- endpoint selection, and whatever authentication hangs off it, depending on URI spelling. It routes on pathFrom(0) now, and a registration that could never match is refused rather than silently unreachable. The rest: - a write that fails part-way has left a partial frame on the wire, so the session is marked unusable instead of merely rethrowing - Java SE reads and writes both flipped the same SocketChannel between blocking and non-blocking with separate selectors; whichever finished first restored the mode under the other. Counted transitions, because a lock held across the select would make a broadcast wait out the reader's idle timeout - the native self-check accepted any 101 without checking Sec-WebSocket-Accept, which is precisely the failure it exists to catch: with SHA-1 returning the wrong digest it reported selfcheck=ok. It uses RFC 6455's own key and verifies the accept value the standard prints -- A/B'd, exit 1 against exit 0 Both arms still pass 301 Autobahn cases strictly green; 163 backend tests, 7 translated; 261 natives resolve. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: ed27baeafe
ℹ️ 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".
The registration API did not belong in this runtime. Everything else here is asked for while the server starts -- a @RestController is found by the build, a Handler is called, Backend.Handlers is invoked with whatever was opened -- and I had added a mutable setter an application pokes at afterwards. In a generated backend there is no such "afterwards": the build writes main, so server.websocket(path, endpoint) is a call the application never makes. So registration is a callback now. HttpServer.start takes WebSocketRoutes and runs it against a WebSocketRegistry before any thread that could accept exists; Backend.Builder takes WebSocketEndpoints, which receives the DataSource and the EntityManager exactly as Handlers does. The generated bootstrap emits that callback instead of a chain of setters. There is no websocket() on a started server any more, and no way to add one: both writes live in a private Registry reachable only from start(). That is a structural fix rather than a described one. The registration race was real -- start binds the listener and starts its pollers before it returns, so an upgrade arriving before the application got its reference back found no route and was answered as ordinary HTTP -- and it is now unrepresentable rather than avoided by convention. It also dissolves the finding about builder paths bypassing validation: there is one path into the map and it validates. Two findings from this round that the redesign does NOT dissolve: The SERVING_WS discount was unreachable. workOutstandingBesidesCaller returns workOutstanding() whenever callerFd < 0, and SERVING_FD is only set around HTTP handlers -- so a websocket callback calling stop() always took that early return and the marker I added for it could never run. A control nothing reaches is not a control. It is consulted before the early return now. And a session that upgraded while stop() was running missed its goodbye: the snapshot is taken while the listener is still accepting, so a session inserted just after it was shut down without a Close and reported 1006 for a shutdown the server performed deliberately. Upgrades are refused with 503 before the snapshot. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The snippet was a `public static Backend start()` that nothing calls, ending in `.start()`, with no cleanup. All three of those are wrong, and the guide already said so about the builder two sections earlier. `.start()` is the test entry point -- its own javadoc says "Tests want this; a process wants run()". It returns the server without installing a signal handler and without waiting, so the snippet bound a port, leaked the server and returned. `run()` is what a main is: bind, install a handler that drains what is in flight, block. The example is a main now and calls run(). The framing was wrong too. The builder is not a normal way to register a websocket; it is the same narrow case the configuration section already names -- a server that writes its own main instead of using the generated one. For an ordinary project the annotation is the whole story and the entry point the build writes does the registration. The section says that first now, and the builder follows as the exception it is. The HttpServer example had the same shape and now ends in awaitTermination(), which is what demo/bench does. The API itself is unchanged: .webSockets(callback) sits beside .handler() and .handlers() and has exactly their use case. What had no use case was the example I wrote for it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>




















































































































































































































































































































































































The server-side backend couldn't serve WebSockets, and couldn't be made to from
outside:
HttpServerhad no upgrade path,Requestexposes no descriptor,drop(fd)owns every close, and aResponsecan only describe a status, a bodyor a file. So this is built in the way
serveHttp2was, rather than bolted on.The gap showed from the client side. Codename One has shipped
com.codename1.io.WebSocketon every port for years, and the only server-sideRFC 6455 code in the tree was two private, hand-rolled, test-only
implementations. An app written in Codename One end to end had a WebSocket
client and nothing of ours to point it at.
The API
An endpoint implements
com.codename1.backend.WebSocket, registered per path onHttpServerorBackend.Builder:Only
onOpen,onTextandonBinaryhave to be written. A PING is answeredwith its PONG before the endpoint is told. Messages arrive whole, and a session
may be written to from any thread, which is what makes a broadcast possible.
What the frame layer refuses
More interesting than what it accepts, because none of these is something a
conformant client produces — which is exactly why they need a test that builds
frames by hand:
front of a proxy is a smuggling primitive)
opcode
a second data frame while one is open
1000..1011 lets through, and which is how the fuzz test caught my own bug
Text is validated as UTF-8 incrementally, so a message that goes wrong in its
first fragment fails there rather than after however much more the peer chose to
send.
Three things this had to avoid rather than participate in
array, which is free for a request and wrong for a websocket, which stops
between every message by design. The upgrade copies out whatever the client
pipelined behind its handshake — a browser routinely puts its first frame in
the same packet — releases the borrow, and owns its buffer from then on.
sweepDeadlinessheds anything idle pastCN1_HTTP_TIMEOUT_MS, which is aimed at a client that began a request andstopped, and is indistinguishable from a websocket doing its job.
activeRequestscount
serve()raised for it. Otherwisestop()waits out its whole windowfor every silent peer, every time.
The test is the screenshot transport, used for real
scripts/lib/cn1ss.shnow starts the backend server, so every on-device UI leg —iOS, watchOS, tvOS, macOS, Catalyst, Android, Java SE and the browser — points
four independent real client stacks at it on every run
(
NSURLSessionWebSocketTask, the hand-rolled Android and Java SE clients, andthe browser's own
WebSocket), sending masked, fragmented,multi-hundred-kilobyte binary messages under ACK-paced flow control.
Replaying a full device conversation against the old server and the new one gives
identical
ACK/NACKtext, identicalCN1SS:INFO:/CN1SS:WARN:lines andbyte-identical files.
CN1SS_WS_SERVERpicks the arm —javaseeverywhere,nativeon theJavaScript leg, which is the only leg that is Linux, already builds ParparVM, is
a single job rather than a matrix, and talks to a client that is not one of ours.
Both are proved with
--selfcheckbefore a leg starts, so a broken server costsseconds rather than a 40-minute timeout.
Two traps worth naming, both found by checking rather than by reasoning:
CN1_BACKEND_HTTPS=0, tempting as it is for avoiding the TLSpackages:
build.shdeletescn1_backend_crypto.cin that mode, and a nativewith no C symbol takes its Java method with it — so
Crypto.sha1would vanishand every handshake would compute the wrong accept.
scripts/common/java/Cn1ssScreenshotServer.javastays. Native Windows hasno backend arm, and
CleanTargetIntegrationTestandscripts/windows/run-hello.batcompile and run that file directly. Itsjavadoc now says so.
The UI workflows now watch
vm/backend, without which a websocket regressionwould break the screenshot transport on every leg while triggering none of them.
Verification
maven/backend, 40 of them new: the frame codec, theresumable UTF-8 validator, the handshake, and 14 that drive a live server over
a real socket through
RawWebSocketClient— a client built to be wrong onpurpose. The one failure is the pre-existing
ValuesTestfloat case, whichfails identically on master.
Utf8.isValidover ~1.9M cases, including real text split at every offset.SHA-1 + base64 over 500 random keys.
scripts/check-native-signatures.sh: the backend's 260 natives all resolve,including the new
shutdownImpl.snippet-compile and Vale gates all clean (Vale: 0/0/0 across 124 files).
Not in this change
permessage-deflate and RFC 8441. Both are noted in Limits worth knowing; every
client falls back correctly without them, and RFC 8441 in particular would
rewrite much of the HTTP/2 request lifecycle that serves ordinary traffic today.
🤖 Generated with Claude Code