mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-10-03 22:19:42 +02:00
fix(api,ws): an input whose delivery fails can be retried instead of being lost
Both input paths recorded the (clientId, seq) pair as applied and acknowledged the frame BEFORE knowing whether the write had landed: the POST route because its mux write is fire-and-forget so the response never waits on a tmux child, the WebSocket handler because it ACKed unconditionally. When the write then failed, the client dropped the frame from its durable queue and the server rejected the retry as a duplicate. The reliable-delivery layer was guaranteeing exactly-once delivery of something that had never been delivered — and `Session.write()` returned void, so a session whose PTY was gone swallowed the data with no signal at all. - `forgetInputSeq()` rolls the bookkeeping back on failure, but only when that seq is still the newest one; a later input has superseded it and must not re-open. - The WebSocket handler withholds its ACK when the write did not land, so the client redelivers. - `Session.write()` reports whether it reached a PTY. Response codes are unchanged, deliberately: a session can legitimately have no PTY yet, and turning that into a failure status would be a contract change of its own. What this does NOT do: remove the root cause. The POST still answers 200 before the mux write is attempted, so a client that treats any 2xx as final cannot learn about that failure. What closes is the narrower window — the write failed AND the ACK never reached the client — plus the whole WebSocket path. Closing the rest would mean awaiting the tmux child inside the request. 9 tests. They drive the HTTP route, not only the Session primitives: with the rollback removed from the route, 2 of them fail. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+26
-4
@@ -2551,11 +2551,16 @@ export class Session extends EventEmitter {
|
||||
* session.write('ls -la\r'); // Command with Enter
|
||||
* ```
|
||||
*/
|
||||
write(data: string): void {
|
||||
/**
|
||||
* @returns true if the data reached a PTY. A session whose PTY is gone silently
|
||||
* swallowed every write before this signal existed, which is how input could
|
||||
* disappear with the caller believing it had been delivered.
|
||||
*/
|
||||
write(data: string): boolean {
|
||||
this._trackSubmit(data);
|
||||
if (this.ptyProcess) {
|
||||
this.ptyProcess.write(data);
|
||||
}
|
||||
if (!this.ptyProcess) return false;
|
||||
this.ptyProcess.write(data);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── Conversation tracking ─────────────────────────────────────────────
|
||||
@@ -2600,6 +2605,23 @@ export class Session extends EventEmitter {
|
||||
* half-open socket silently drops frames with no error) would type a prompt
|
||||
* twice whenever an ACK is lost after the write landed.
|
||||
*/
|
||||
/**
|
||||
* Undo the bookkeeping of {@link shouldApplyInput} for a delivery that failed.
|
||||
*
|
||||
* Without this, the reliable-delivery layer guarantees exactly-once delivery of
|
||||
* something that may never have been delivered: the seq is recorded as applied
|
||||
* BEFORE the write is attempted, so a client retry — the very mechanism the seq
|
||||
* exists for — is rejected as a duplicate and the input is lost for good.
|
||||
*
|
||||
* Only rolls back if `seq` is still the newest recorded one; a later input has
|
||||
* already superseded it and must not be re-opened.
|
||||
*/
|
||||
forgetInputSeq(clientId: string, seq: number): void {
|
||||
if (this._appliedInputSeq.get(clientId) === seq) {
|
||||
this._appliedInputSeq.set(clientId, seq - 1);
|
||||
}
|
||||
}
|
||||
|
||||
shouldApplyInput(clientId: string, seq: number): boolean {
|
||||
const last = this._appliedInputSeq.get(clientId);
|
||||
if (last !== undefined && seq <= last) return false;
|
||||
|
||||
Reference in New Issue
Block a user