From 296f0ce309e402acc18deeb0f52f7b05b6496631 Mon Sep 17 00:00:00 2001 From: Ivan Z Date: Mon, 31 Aug 2026 01:38:57 -0700 Subject: [PATCH 1/5] fix: Adding validation for raw data size parsing --- trimsock.gd/.gitignore | 2 + trimsock.gd/addons/trimsock.gd/line_parser.gd | 3 + trimsock.gd/addons/trimsock.gd/line_reader.gd | 22 ++- trimsock.gd/addons/trimsock.gd/reactor.gd | 10 +- trimsock.gd/addons/trimsock.gd/reader.gd | 91 +++++++++-- trimsock.gd/tests/reactor.test.gd | 18 ++ trimsock.gd/tests/reader.test.gd | 131 +++++++++++++++ trimsock.js/.gitignore | 1 + .../trimsock-bun/lib/bun.socket.reactor.ts | 2 + .../packages/trimsock-js/lib/reader.ts | 66 +++++++- .../trimsock-js/spec/reactor/reactor.spec.ts | 29 ++++ .../packages/trimsock-js/spec/reader.spec.ts | 154 +++++++++++++++++- .../trimsock-node/lib/node.socket.reactor.ts | 2 + 13 files changed, 499 insertions(+), 32 deletions(-) diff --git a/trimsock.gd/.gitignore b/trimsock.gd/.gitignore index f51cb86..7a17a33 100644 --- a/trimsock.gd/.gitignore +++ b/trimsock.gd/.gitignore @@ -3,3 +3,5 @@ # Only for local use sh/ensure-uids.sh + +.vscode/ diff --git a/trimsock.gd/addons/trimsock.gd/line_parser.gd b/trimsock.gd/addons/trimsock.gd/line_parser.gd index 5dc0e86..d7e3389 100644 --- a/trimsock.gd/addons/trimsock.gd/line_parser.gd +++ b/trimsock.gd/addons/trimsock.gd/line_parser.gd @@ -34,6 +34,9 @@ func parse(p_line: String) -> TrimsockCommand: return command func read_name() -> String: + if is_eol(): + return "" + if chr() == "\"": return read_quoted() else: diff --git a/trimsock.gd/addons/trimsock.gd/line_reader.gd b/trimsock.gd/addons/trimsock.gd/line_reader.gd index fabb6bb..825c975 100644 --- a/trimsock.gd/addons/trimsock.gd/line_reader.gd +++ b/trimsock.gd/addons/trimsock.gd/line_reader.gd @@ -7,10 +7,16 @@ var at := 0 var is_quote := false var is_escape := false +func reset() -> void: + buffer.clear() + at = 0 + is_quote = false + is_escape = false + func ingest(data: PackedByteArray) -> Error: var new_size := buffer.size() + data.size() if new_size > max_size: - buffer.clear() + reset() return ERR_OUT_OF_MEMORY buffer.append_array(data) @@ -30,20 +36,26 @@ func read_text() -> String: return "" func has_data(size: int) -> bool: - return buffer.size() >= size + return buffer.size() > size -func read_data(size: int) -> PackedByteArray: +func read_data(size: int) -> Array: assert(has_data(size), "Trying to read more bytes than available!") # Grab result var result := buffer.slice(0, size) - buffer = buffer.slice(size) + var is_terminated := String.chr(buffer[size]) == "\n" + + buffer = buffer.slice(size + 1) + at = 0 # Reset flags is_escape = false is_quote = false - return result + if not is_terminated: + return [ERR_PARSE_ERROR, PackedByteArray()] + + return [OK, result] func chr() -> String: return String.chr(buffer[at]) diff --git a/trimsock.gd/addons/trimsock.gd/reactor.gd b/trimsock.gd/addons/trimsock.gd/reactor.gd index b80bc29..e77e845 100644 --- a/trimsock.gd/addons/trimsock.gd/reactor.gd +++ b/trimsock.gd/addons/trimsock.gd/reactor.gd @@ -37,6 +37,8 @@ var _id_generator: TrimsockIDGenerator = RandomTrimsockIDGenerator.new(12) signal on_attach(source: Variant) ## Emitted when a known source is detached from the reactor signal on_detach(source: Variant) +## Emitted when data received from a source can't be parsed +signal on_ingest_error(source: Variant, error: Error, message: String) ## Poll all sources and process incoming data @@ -48,6 +50,8 @@ func poll() -> void: while true: var command := reader.read() if not command: + if reader.last_error != OK: + on_ingest_error.emit(source, reader.last_error, reader.last_error_message) break _handle(command, source) @@ -155,7 +159,11 @@ func _write(target: Variant, command: TrimsockCommand) -> void: func _ingest(source: Variant, data: PackedByteArray) -> Error: assert(_readers.has(source), "Ingesting data from unknown source! Did you call `attach()`?") var reader := _readers[source] as TrimsockReader - return reader.ingest_bytes(data) + var error := reader.ingest_bytes(data) + if error != OK: + on_ingest_error.emit(source, error, reader.last_error_message) + + return error func _handle(command: TrimsockCommand, source: Variant) -> void: var xchg := _get_exchange_for(command, source) diff --git a/trimsock.gd/addons/trimsock.gd/reader.gd b/trimsock.gd/addons/trimsock.gd/reader.gd index 6cc7b3c..2edd1bd 100644 --- a/trimsock.gd/addons/trimsock.gd/reader.gd +++ b/trimsock.gd/addons/trimsock.gd/reader.gd @@ -9,31 +9,62 @@ class_name TrimsockReader ## [br][br] ## After ingestion, call [method read] to extract commands from the ingested ## data. Data that is parsed is immediately freed from the internal buffer. +## [br][br] +## If the incoming data is malformed, it is discarded and the error is reported +## in [member last_error]. The reader continues parsing with the data following +## the offending command. + +## Upper limit on the internal buffer's size +## +## Ingested data is buffered until [method read] can extract a full command +## from it. If this size limit is exceeded, the buffer's contents are discarded +## and [constant ERR_OUT_OF_MEMORY] is reported. +var max_size: int: + get: + return _line_reader.max_size + set(value): + _line_reader.max_size = value + +var last_error: Error = OK +## Description of [member last_error] +var last_error_message := "" var _line_reader: _TrimsockLineReader = _TrimsockLineReader.new() var _line_parser: _TrimsockLineParser = _TrimsockLineParser.new() var _queued_raw: TrimsockCommand = null +var _queued_raw_size := -1 ## Ingest incoming text ## [br][br] ## Returns [constant OK] on success, or [constant ERR_OUT_OF_MEMORY] if the ## internal buffer can't store the data. func ingest_text(text: String) -> Error: - return _line_reader.ingest(text.to_utf8_buffer()) + return ingest_bytes(text.to_utf8_buffer()) ## Ingest incoming binary data ## [br][br] ## Returns [constant OK] on success, or [constant ERR_OUT_OF_MEMORY] if the ## internal buffer can't store the data. func ingest_bytes(bytes: PackedByteArray) -> Error: - return _line_reader.ingest(bytes) + _clear_error() + + var error := _line_reader.ingest(bytes) + if error != OK: + _dequeue_raw() + _set_error(error, "Buffer overflow! Can't ingest %d bytes without exceeding %d!" \ + % [bytes.size(), max_size]) + + return error ## Try and extract a command from the ingested data ## [br][br] ## Returns a parsed command, or [code]null[/code] if no command is available -## yet. +## yet. Malformed data is rejected by returning [code]null[/code] and setting +## [member last_error]. func read() -> TrimsockCommand: + _clear_error() + var command := _pop() if command: _TrimsockConventions.apply(command) @@ -42,17 +73,24 @@ func read() -> TrimsockCommand: func _pop() -> TrimsockCommand: # We read a raw command earlier, waiting to have enough data if _queued_raw: - var data_size := int(_queued_raw.text) - if not _line_reader.has_data(data_size): + if not _line_reader.has_data(_queued_raw_size): + return null + + var raw_command := _queued_raw + var raw_size := _queued_raw_size + var read_result := _line_reader.read_data(raw_size) + + _dequeue_raw() + + if read_result[0] != OK: + _set_error(read_result[0], "Expected newline after %d bytes of raw data!" % [raw_size]) return null - _queued_raw.raw = _line_reader.read_data(data_size) - _queued_raw.text = "" - _queued_raw.chunks.clear() + raw_command.raw = read_result[1] + raw_command.text = "" + raw_command.chunks.clear() - var result := _queued_raw - _queued_raw = null - return result + return raw_command # No queued command, try to read a new one var line := _line_reader.read_text() @@ -61,11 +99,42 @@ func _pop() -> TrimsockCommand: var command := _line_parser.parse(line) if command.is_raw: + var data_size := _parse_data_size(command.text) + if data_size < 0: + _set_error(ERR_INVALID_DATA, "Invalid raw command size: \"%s\"!" % [command.text]) + return null + # Command is raw, we'll keep it in the queue until we read the binary # data for it _queued_raw = command + _queued_raw_size = data_size # Try getting it immediately, in case we already have the data in buffer return _pop() return command + +# Parse the data size of a raw command or return -1 if it's invalid +func _parse_data_size(text: String) -> int: + if not text.is_valid_int(): + return -1 + + var size := text.to_int() + + # The data and its terminating newline must both fit in the buffer + if size < 0 or size >= max_size: + return -1 + + return size + +func _dequeue_raw() -> void: + _queued_raw = null + _queued_raw_size = -1 + +func _clear_error() -> void: + last_error = OK + last_error_message = "" + +func _set_error(error: Error, message: String) -> void: + last_error = error + last_error_message = message diff --git a/trimsock.gd/tests/reactor.test.gd b/trimsock.gd/tests/reactor.test.gd index a58e468..21bad00 100644 --- a/trimsock.gd/tests/reactor.test.gd +++ b/trimsock.gd/tests/reactor.test.gd @@ -99,3 +99,21 @@ func suite(): expect(reactor.outbox[0].command.is_stream(), "Command was not a stream!") expect_not_empty(reactor.outbox[0].command.exchange_id, "Stream ID was empty!") ) + + test("should keep parsing after an unsatisfiable raw command", func(): + var errors := [] + reactor.on_ingest_error.connect(func(source, error, message): errors.append(error)) + + var commands := [] + reactor.on("command", func(cmd, xchg): commands.append(cmd)) + + # The blank line terminating the headers parses as a raw command with no size + reactor.ingest_text(some_source, "GET / HTTP/1.1\r\nHost: x\r\n\r\n") + reactor.poll() + + reactor.ingest_text(some_source, "command foo\n") + reactor.poll() + + expect_not_empty(errors, "No errors reported!") + expect_not_empty(commands, "No commands handled!") + ) diff --git a/trimsock.gd/tests/reader.test.gd b/trimsock.gd/tests/reader.test.gd index 98ce24e..a113cb2 100644 --- a/trimsock.gd/tests/reader.test.gd +++ b/trimsock.gd/tests/reader.test.gd @@ -19,3 +19,134 @@ func suite(): expect_true(command.is_raw) expect_equal(command.raw, "a\ncd".to_utf8_buffer()) ) + + define("raw data terminator", func(): + test("should read raw message split at its terminator", func(): + reader.ingest_text("\rcommand 4\n1234") + expect_null(reader.read(), "Command was read without its terminator!") + + reader.ingest_text("\n") + var command := reader.read() + + expect_not_null(command) + expect_equal(command.raw, "1234".to_utf8_buffer()) + ) + + test("should not emit a command for the terminator", func(): + reader.ingest_text("\rcommand 4\n1234\ncommand after\n") + + expect_equal(read_names(), ["command", "command"]) + ) + + test("should reject raw data with a malformed terminator", func(): + reader.ingest_text("\rcommand 4\n1234X\n") + + expect_null(reader.read(), "Command was read!") + expect_not_equal(reader.last_error, OK, "No error was reported!") + ) + + test("should keep parsing after a malformed terminator", func(): + # The malformed terminator is consumed in its place, so parsing + # resumes on the next line + reader.ingest_text("\rcommand 4\n1234Xcommand foo\n") + expect_null(reader.read(), "Command was read!") + + expect_equal(read_names(), ["command"]) + ) + ) + + define("unsatisfiable raw commands", func(): + check_invalid_raw("missing size", "\rcommand\n") + check_invalid_raw("non-numeric size", "\rcommand foo\n") + check_invalid_raw("blank line", "\r\n") + check_invalid_raw("negative size", "\rcommand -4\n") + check_invalid_raw("size over max_size", "\rcommand 100000\n") + check_invalid_raw("size just over max_size", "\rcommand 16385\n") + # The terminating newline wouldn't fit in the buffer + check_invalid_raw("size at max_size", "\rcommand 16384\n") + + test("should keep parsing after rejecting a raw command", func(): + reader.ingest_text("\r\n") + expect_null(reader.read(), "Command was read!") + expect_not_equal(reader.last_error, OK, "No error was reported!") + + reader.ingest_text("command foo\n") + expect_equal(read_names(), ["command"]) + ) + + test("should accept raw command sized up to max_size", func(): + reader.max_size = 16 + + # 15 bytes of data plus the terminating newline exactly fill the buffer + reader.ingest_text("\rcommand 15\n") + expect_null(reader.read(), "Command was read without data!") + + reader.ingest_text("012345678901234\n") + var command := reader.read() + + expect_not_null(command) + expect_equal(command.raw, "012345678901234".to_utf8_buffer()) + ) + ) + + define("buffer overflow", func(): + test("should reject data over max_size", func(): + reader.max_size = 8 + + expect_not_equal(reader.ingest_text("command foobar\n"), OK) + ) + + test("should resume parsing after discarding the buffer", func(): + reader.max_size = 16 + + reader.ingest_text("012345678901") + expect_null(reader.read(), "Command was read!") + + expect_not_equal(reader.ingest_text("01234"), OK, "No overflow!") + + reader.ingest_text("command\n") + expect_equal(read_names(), ["command"]) + ) + + test("should reset quote state after discarding the buffer", func(): + reader.max_size = 16 + + reader.ingest_text("\"") + expect_null(reader.read(), "Command was read!") + + expect_not_equal(reader.ingest_text("0123456789012345"), OK, "No overflow!") + + reader.ingest_text("command foo\n") + expect_equal(read_names(), ["command"]) + ) + + test("should drop queued raw command after discarding the buffer", func(): + reader.max_size = 16 + + reader.ingest_text("\rcommand 8\n") + expect_null(reader.read(), "Command was read without data!") + + expect_not_equal(reader.ingest_text("0".repeat(17)), OK, "No overflow!") + + reader.ingest_text("command foo\n") + expect_equal(read_names(), ["command"]) + ) + ) + +func check_invalid_raw(name: String, input: String) -> void: + test("should reject raw command with " + name, func(): + reader.ingest_text(input) + + expect_null(reader.read(), "Command was read!") + expect_not_equal(reader.last_error, OK, "No error was reported!") + ) + +# Read all the commands available and return their names +func read_names() -> Array: + var names := [] + while true: + var command := reader.read() + if not command: break + names.append(command.name) + + return names diff --git a/trimsock.js/.gitignore b/trimsock.js/.gitignore index e0473bd..8dc99a6 100644 --- a/trimsock.js/.gitignore +++ b/trimsock.js/.gitignore @@ -158,6 +158,7 @@ dist # Stores VSCode versions used for testing VSCode extensions +.vscode .vscode-test # yarn v2 diff --git a/trimsock.js/packages/trimsock-bun/lib/bun.socket.reactor.ts b/trimsock.js/packages/trimsock-bun/lib/bun.socket.reactor.ts index 6ba9138..0c8d00a 100644 --- a/trimsock.js/packages/trimsock-bun/lib/bun.socket.reactor.ts +++ b/trimsock.js/packages/trimsock-bun/lib/bun.socket.reactor.ts @@ -75,12 +75,14 @@ export class BunSocketReactor extends Reactor< }, close: (socket, error) => { baseHandlers.close?.call(baseHandlers.close, socket, error); + this.detach(socket); }, drain: (socket) => { baseHandlers.drain?.call(baseHandlers.drain, socket); }, error: (socket, error) => { baseHandlers.error?.call(baseHandlers.error, socket, error); + this.detach(socket); }, }, }; diff --git a/trimsock.js/packages/trimsock-js/lib/reader.ts b/trimsock.js/packages/trimsock-js/lib/reader.ts index a7eacda..3f1d42d 100644 --- a/trimsock.js/packages/trimsock-js/lib/reader.ts +++ b/trimsock.js/packages/trimsock-js/lib/reader.ts @@ -4,7 +4,11 @@ import { RequestResponseConvention, StreamConvention, } from "./conventions.js"; -import { BufferOverflowError, UnexpectedCharacterError } from "./errors.js"; +import { + BufferOverflowError, + ParserError, + UnexpectedCharacterError +} from "./errors.js"; /* * Converts the ingested data into either command lines that can be parsed, or @@ -18,10 +22,17 @@ class CommandReader { private isQuote = false; private isEscape = false; + reset() { + this.buffer = Buffer.of(); + this.at = 0; + this.isQuote = false; + this.isEscape = false; + } + ingest(data: Buffer) { const newSize = this.buffer.byteLength + data.byteLength; if (newSize > this.maxSize) { - this.buffer = Buffer.of(); + this.reset(); throw new BufferOverflowError( `Buffer overflow! New size ${newSize} exceeds ${this.maxSize}!`, @@ -58,11 +69,23 @@ class CommandReader { this.isEscape = false; this.isQuote = false; - if (this.buffer.length >= size) { + // The raw data is followed by a terminating newline, so both need to + // arrive before the command can be extracted + if (this.buffer.length > size) { const result = this.buffer.subarray(0, size); + + this.at = size; + const isTerminated = this.char === "\n"; + this.at = size + 1; this.flush(); + // Check for correct termination after flush to ensure reader remains in a valid state + if (!isTerminated) + throw new UnexpectedCharacterError( + `Expected newline after ${size} bytes of raw data!`, + ); + return result; } } @@ -222,6 +245,8 @@ export class TrimsockReader { * * If this size limit is exceeded, the buffer's contents are discarded and a * {@link BufferOverflowError} is thrown. + * + * Throws {@link ParserError} if command size is invalid. */ public maxSize = 16384; @@ -245,8 +270,14 @@ export class TrimsockReader { ingest(data: Buffer | string) { this.reader.maxSize = this.maxSize; - if (typeof data === "string") this.reader.ingest(Buffer.from(data, "utf8")); - else this.reader.ingest(data); + try { + if (typeof data === "string") + this.reader.ingest(Buffer.from(data, "utf8")); + else this.reader.ingest(data); + } catch (e) { + this.dequeueRaw(); + throw e; + } } /** @@ -284,7 +315,13 @@ export class TrimsockReader { private pop(): CommandSpec | undefined { if (this.queuedRawCommand !== undefined) { - const data = this.reader.readRaw(this.queuedRawSize); + let data: Buffer | undefined; + try { + data = this.reader.readRaw(this.queuedRawSize); + } catch (e) { + this.dequeueRaw(); + throw e; + } if (!data) return; const result: CommandSpec = { @@ -292,7 +329,7 @@ export class TrimsockReader { raw: data, }; - this.queuedRawCommand = undefined; + this.dequeueRaw(); return result; } @@ -301,11 +338,24 @@ export class TrimsockReader { const command = this.parser.parse(line); if (command.raw !== undefined) { + const size = Number.parseInt(command.text ?? ""); + + // The data and the terminating newline must both fit in the buffer + if (!Number.isInteger(size) || size < 0 || size >= this.maxSize) + throw new ParserError( + `Invalid raw command size: ${command.text}`, + ); + this.queuedRawCommand = command; - this.queuedRawSize = Number.parseInt(command.text ?? ""); + this.queuedRawSize = size; return this.read(); } return command; } + + private dequeueRaw() { + this.queuedRawCommand = undefined; + this.queuedRawSize = -1; + } } diff --git a/trimsock.js/packages/trimsock-js/spec/reactor/reactor.spec.ts b/trimsock.js/packages/trimsock-js/spec/reactor/reactor.spec.ts index 6e0948e..57c6277 100644 --- a/trimsock.js/packages/trimsock-js/spec/reactor/reactor.spec.ts +++ b/trimsock.js/packages/trimsock-js/spec/reactor/reactor.spec.ts @@ -24,6 +24,21 @@ describe("Reactor", () => { expect(reactor.outbox).toBeEmpty(); }); + test("should keep parsing after an unsatisfiable raw command", async () => { + const errors: unknown[] = []; + reactor.onIngestError((error) => errors.push(error)); + reactor.on("echo", (cmd, xchg) => { + xchg.replyOrSend(cmd); + }); + + // The blank line terminating the headers parses as a raw command with no size. + await reactor.ingest("GET / HTTP/1.1\r\nHost: x\r\n\r\n", "0"); + await reactor.ingest("echo foo\n", "0"); + + expect(errors).not.toBeEmpty(); + expect(reactor.outbox).not.toBeEmpty(); + }); + test("should not throw on unknown exchange", async () => { expect( async () => await reactor.ingest(".1234 foo\n", "0"), @@ -52,6 +67,20 @@ describe("Reactor", () => { }); }); + describe("detach()", () => { + test("should free the source's reader", async () => { + const handler = mock(); + reactor.on("command", handler); + + await reactor.ingest("comm", "0"); + reactor.detach("0"); + + await reactor.ingest("and foo\n", "0"); + + expect(handler.mock.calls).toBeEmpty(); + }); + }); + describe("knownCommands", () => { test("should return list", () => { reactor.on("foo", () => {}); diff --git a/trimsock.js/packages/trimsock-js/spec/reader.spec.ts b/trimsock.js/packages/trimsock-js/spec/reader.spec.ts index 27f00a4..1591636 100644 --- a/trimsock.js/packages/trimsock-js/spec/reader.spec.ts +++ b/trimsock.js/packages/trimsock-js/spec/reader.spec.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test"; import type { CommandSpec } from "@lib/command.js"; -import { BufferOverflowError } from "@lib/errors.js"; +import { + BufferOverflowError, + ParserError, + UnexpectedCharacterError, +} from "@lib/errors.js"; import { TrimsockReader } from "@lib/reader.js"; type Kase = [string, string[] | string, CommandSpec[]]; @@ -166,6 +170,18 @@ describe("TrimsockReader", () => { ["\rcommand 1", "0\n", "0123", "456789\n"], [{ name: "command", raw: Buffer.from("0123456789") }], ], + [ + "should parse raw command split at its terminator", + ["\rcommand 4\n", "1234", "\ncommand after\n"], + [ + { name: "command", raw: Buffer.from("1234") }, + { + name: "command", + text: "after", + chunks: [{ text: "after", isQuoted: false }], + }, + ], + ], [ "should parse regular and raw commands", ["command foo\n\rraw 4\n1234\ncommand bar\n"], @@ -185,13 +201,137 @@ describe("TrimsockReader", () => { ], ])); - test("should throw on buffer overflow", () => { - const reader = new TrimsockReader(); - reader.maxSize = 8; + describe("unsatisfiable raw commands", () => { + const kases: [string, string][] = [ + ["missing size", "\rcommand\n"], + ["non-numeric size", "\rcommand foo\n"], + ["blank line", "\r\n"], + ["negative size", "\rcommand -4\n"], + ["size over maxSize", "\rcommand 100000\n"], + ["size just over maxSize", "\rcommand 16385\n"], + // The terminating newline wouldn't fit in the buffer + ["size at maxSize", "\rcommand 16384\n"], + ]; + + for (const [name, input] of kases) + test(`should reject raw command with ${name}`, () => { + const reader = new TrimsockReader(); + reader.ingest(input); - expect(() => reader.ingest("command foobar\n")).toThrow( - BufferOverflowError, - ); + expect(() => [...reader.commands()]).toThrow(ParserError); + }); + + test("should keep parsing after rejecting a raw command", () => { + const reader = new TrimsockReader(); + + reader.ingest("\r\n"); + expect(() => [...reader.commands()]).toThrow(ParserError); + + reader.ingest("command foo\n"); + expect([...reader.commands()].map((it) => it.name)).toEqual(["command"]); + }); + + test("should accept raw command sized up to maxSize", () => { + const reader = new TrimsockReader(); + reader.maxSize = 16; + + // 15 bytes of data plus the terminating newline exactly fill the buffer + reader.ingest("\rcommand 15\n"); + expect([...reader.commands()]).toBeEmpty(); + + reader.ingest("012345678901234\n"); + expect([...reader.commands()]).toEqual([ + { name: "command", raw: Buffer.from("012345678901234") }, + ]); + }); + }); + + describe("raw data terminator", () => { + test("should not emit a command for the terminator", () => { + const reader = new TrimsockReader(); + + reader.ingest("\rcommand 4\n"); + expect([...reader.commands()]).toBeEmpty(); + + // The data arrives without its terminator + reader.ingest("1234"); + expect([...reader.commands()]).toBeEmpty(); + + reader.ingest("\n"); + expect([...reader.commands()]).toEqual([ + { name: "command", raw: Buffer.from("1234") }, + ]); + }); + + test("should reject raw data with a malformed terminator", () => { + const reader = new TrimsockReader(); + reader.ingest("\rcommand 4\n1234X\n"); + + expect(() => [...reader.commands()]).toThrow(UnexpectedCharacterError); + }); + + test("should keep parsing after a malformed terminator", () => { + const reader = new TrimsockReader(); + + // The malformed terminator is consumed in its place, so parsing resumes + // on the next line + reader.ingest("\rcommand 4\n1234Xcommand foo\n"); + expect(() => [...reader.commands()]).toThrow(UnexpectedCharacterError); + + expect([...reader.commands()].map((it) => it.name)).toEqual(["command"]); + }); + }); + + describe("buffer overflow", () => { + test("should throw on buffer overflow", () => { + const reader = new TrimsockReader(); + reader.maxSize = 8; + + expect(() => reader.ingest("command foobar\n")).toThrow( + BufferOverflowError, + ); + }); + + test("should resume parsing after discarding the buffer", () => { + const reader = new TrimsockReader(); + reader.maxSize = 16; + + reader.ingest("012345678901"); + expect([...reader.commands()]).toBeEmpty(); + + expect(() => reader.ingest("01234")).toThrow(BufferOverflowError); + + reader.ingest("command\n"); + expect([...reader.commands()].map((it) => it.name)).toEqual(["command"]); + }); + + test("should reset quote state after discarding the buffer", () => { + const reader = new TrimsockReader(); + reader.maxSize = 16; + + reader.ingest('"'); + expect([...reader.commands()]).toBeEmpty(); + + expect(() => reader.ingest("0123456789012345")).toThrow( + BufferOverflowError, + ); + + reader.ingest("command foo\n"); + expect([...reader.commands()].map((it) => it.name)).toEqual(["command"]); + }); + + test("should drop queued raw command after discarding the buffer", () => { + const reader = new TrimsockReader(); + reader.maxSize = 16; + + reader.ingest("\rcommand 8\n"); + expect([...reader.commands()]).toBeEmpty(); + + expect(() => reader.ingest("0".repeat(17))).toThrow(BufferOverflowError); + + reader.ingest("command foo\n"); + expect([...reader.commands()].map((it) => it.name)).toEqual(["command"]); + }); }); }); diff --git a/trimsock.js/packages/trimsock-node/lib/node.socket.reactor.ts b/trimsock.js/packages/trimsock-node/lib/node.socket.reactor.ts index a5d627c..a7c4eaf 100644 --- a/trimsock.js/packages/trimsock-node/lib/node.socket.reactor.ts +++ b/trimsock.js/packages/trimsock-node/lib/node.socket.reactor.ts @@ -32,6 +32,7 @@ export class NodeSocketReactor extends Reactor { .createServer(options, connectionListener) .on("connection", (socket: net.Socket) => { socket.on("data", (data: Buffer) => this.ingest(data, socket)); + socket.on("close", () => this.detach(socket)); }); } @@ -52,6 +53,7 @@ export class NodeSocketReactor extends Reactor { ): net.Socket { const socket = net.createConnection(options, connectionListener); socket.on("data", (data: Buffer) => this.ingest(data, socket)); + socket.on("close", () => this.detach(socket)); return socket; } From c7fef4f00bbe127717e26422fb2ffcd185836df2 Mon Sep 17 00:00:00 2001 From: Ivan Z Date: Wed, 9 Sep 2026 01:43:57 -0700 Subject: [PATCH 2/5] fix:Adding a result class, addressing comments for PR85 --- trimsock.gd/addons/trimsock.gd/line_reader.gd | 8 +- trimsock.gd/addons/trimsock.gd/reactor.gd | 18 ++- trimsock.gd/addons/trimsock.gd/reader.gd | 82 +++++------ trimsock.gd/addons/trimsock.gd/result.gd | 133 ++++++++++++++++++ trimsock.gd/addons/trimsock.gd/result.gd.uid | 1 + trimsock.gd/tests/conventions.test.gd | 6 +- trimsock.gd/tests/reactor.test.gd | 5 +- trimsock.gd/tests/reader.perf.gd | 2 +- trimsock.gd/tests/reader.test.gd | 61 ++++---- trimsock.js/.gitattributes | 2 + trimsock.js/bun.lockb | Bin 19352 -> 19904 bytes .../trimsock-bun/lib/bun.socket.reactor.ts | 2 +- .../packages/trimsock-js/lib/reader.ts | 6 +- .../packages/trimsock-js/spec/reader.spec.ts | 10 +- .../trimsock-node/lib/node.socket.reactor.ts | 2 + 15 files changed, 240 insertions(+), 98 deletions(-) create mode 100644 trimsock.gd/addons/trimsock.gd/result.gd create mode 100644 trimsock.gd/addons/trimsock.gd/result.gd.uid create mode 100644 trimsock.js/.gitattributes diff --git a/trimsock.gd/addons/trimsock.gd/line_reader.gd b/trimsock.gd/addons/trimsock.gd/line_reader.gd index 825c975..977727e 100644 --- a/trimsock.gd/addons/trimsock.gd/line_reader.gd +++ b/trimsock.gd/addons/trimsock.gd/line_reader.gd @@ -7,6 +7,7 @@ var at := 0 var is_quote := false var is_escape := false + func reset() -> void: buffer.clear() at = 0 @@ -38,7 +39,7 @@ func read_text() -> String: func has_data(size: int) -> bool: return buffer.size() > size -func read_data(size: int) -> Array: +func read_data(size: int) -> TrimsockResult.Data: assert(has_data(size), "Trying to read more bytes than available!") # Grab result @@ -53,9 +54,10 @@ func read_data(size: int) -> Array: is_quote = false if not is_terminated: - return [ERR_PARSE_ERROR, PackedByteArray()] + return TrimsockResult.Data.of_error(ERR_PARSE_ERROR, + "Expected newline after %d bytes of raw data!" % [size]) - return [OK, result] + return TrimsockResult.Data.of_value(result) func chr() -> String: return String.chr(buffer[at]) diff --git a/trimsock.gd/addons/trimsock.gd/reactor.gd b/trimsock.gd/addons/trimsock.gd/reactor.gd index e77e845..b46a363 100644 --- a/trimsock.gd/addons/trimsock.gd/reactor.gd +++ b/trimsock.gd/addons/trimsock.gd/reactor.gd @@ -48,10 +48,12 @@ func poll() -> void: for source in _sources: var reader := _readers[source] as TrimsockReader while true: - var command := reader.read() + var result := reader.read() + var command := result.value() if not command: - if reader.last_error != OK: - on_ingest_error.emit(source, reader.last_error, reader.last_error_message) + if not result.is_success(): + var error := result.error() + on_ingest_error.emit(source, error.code, error.message) break _handle(command, source) @@ -159,11 +161,13 @@ func _write(target: Variant, command: TrimsockCommand) -> void: func _ingest(source: Variant, data: PackedByteArray) -> Error: assert(_readers.has(source), "Ingesting data from unknown source! Did you call `attach()`?") var reader := _readers[source] as TrimsockReader - var error := reader.ingest_bytes(data) - if error != OK: - on_ingest_error.emit(source, error, reader.last_error_message) + var result := reader.ingest_bytes(data) + if not result.is_success(): + var error := result.error() + on_ingest_error.emit(source, error.code, error.message) + return error.code - return error + return OK func _handle(command: TrimsockCommand, source: Variant) -> void: var xchg := _get_exchange_for(command, source) diff --git a/trimsock.gd/addons/trimsock.gd/reader.gd b/trimsock.gd/addons/trimsock.gd/reader.gd index 2edd1bd..e38be4d 100644 --- a/trimsock.gd/addons/trimsock.gd/reader.gd +++ b/trimsock.gd/addons/trimsock.gd/reader.gd @@ -11,8 +11,8 @@ class_name TrimsockReader ## data. Data that is parsed is immediately freed from the internal buffer. ## [br][br] ## If the incoming data is malformed, it is discarded and the error is reported -## in [member last_error]. The reader continues parsing with the data following -## the offending command. +## in the returned [TrimsockResult]. The reader continues parsing with the data +## following the offending command. ## Upper limit on the internal buffer's size @@ -26,10 +26,6 @@ var max_size: int: set(value): _line_reader.max_size = value -var last_error: Error = OK -## Description of [member last_error] -var last_error_message := "" - var _line_reader: _TrimsockLineReader = _TrimsockLineReader.new() var _line_parser: _TrimsockLineParser = _TrimsockLineParser.new() var _queued_raw: TrimsockCommand = null @@ -37,82 +33,78 @@ var _queued_raw_size := -1 ## Ingest incoming text ## [br][br] -## Returns [constant OK] on success, or [constant ERR_OUT_OF_MEMORY] if the -## internal buffer can't store the data. -func ingest_text(text: String) -> Error: +## Returns a successful result, or an error result with +## [constant ERR_OUT_OF_MEMORY] if the internal buffer can't store the data. +func ingest_text(text: String) -> TrimsockResult: return ingest_bytes(text.to_utf8_buffer()) ## Ingest incoming binary data ## [br][br] -## Returns [constant OK] on success, or [constant ERR_OUT_OF_MEMORY] if the -## internal buffer can't store the data. -func ingest_bytes(bytes: PackedByteArray) -> Error: - _clear_error() - +## Returns a successful result, or an error result with +## [constant ERR_OUT_OF_MEMORY] if the internal buffer can't store the data. +func ingest_bytes(bytes: PackedByteArray) -> TrimsockResult: var error := _line_reader.ingest(bytes) if error != OK: _dequeue_raw() - _set_error(error, "Buffer overflow! Can't ingest %d bytes without exceeding %d!" \ + return TrimsockResult.failure(error, + "Buffer overflow! Can't ingest %d bytes without exceeding %d!" \ % [bytes.size(), max_size]) - return error + return TrimsockResult.success() ## Try and extract a command from the ingested data ## [br][br] -## Returns a parsed command, or [code]null[/code] if no command is available -## yet. Malformed data is rejected by returning [code]null[/code] and setting -## [member last_error]. -func read() -> TrimsockCommand: - _clear_error() - - var command := _pop() +## Returns a result holding the parsed command, or a successful result without +## a command if none is available yet. Malformed data is rejected by returning +## an error result. +func read() -> TrimsockResult.Command: + var result := _pop() + var command := result.value() if command: _TrimsockConventions.apply(command) - return command + return result -func _pop() -> TrimsockCommand: +func _pop() -> TrimsockResult.Command: # We read a raw command earlier, waiting to have enough data if _queued_raw: if not _line_reader.has_data(_queued_raw_size): - return null + return TrimsockResult.Command.of_success() var raw_command := _queued_raw - var raw_size := _queued_raw_size - var read_result := _line_reader.read_data(raw_size) + var read_result := _line_reader.read_data(_queued_raw_size) _dequeue_raw() - if read_result[0] != OK: - _set_error(read_result[0], "Expected newline after %d bytes of raw data!" % [raw_size]) - return null + if not read_result.is_success(): + var error := read_result.error() + return TrimsockResult.Command.of_error(error.code, error.message) - raw_command.raw = read_result[1] + raw_command.raw = read_result.value() raw_command.text = "" raw_command.chunks.clear() - return raw_command + return TrimsockResult.Command.of_value(raw_command) # No queued command, try to read a new one var line := _line_reader.read_text() if not line: - return null + return TrimsockResult.Command.of_success() var command := _line_parser.parse(line) if command.is_raw: var data_size := _parse_data_size(command.text) if data_size < 0: - _set_error(ERR_INVALID_DATA, "Invalid raw command size: \"%s\"!" % [command.text]) - return null + return TrimsockResult.Command.of_error(ERR_INVALID_DATA, + "Invalid raw command size: \"%s\"!" % [command.text]) # Command is raw, we'll keep it in the queue until we read the binary # data for it - _queued_raw = command - _queued_raw_size = data_size + _queue_raw(command, data_size) # Try getting it immediately, in case we already have the data in buffer return _pop() - return command + return TrimsockResult.Command.of_value(command) # Parse the data size of a raw command or return -1 if it's invalid func _parse_data_size(text: String) -> int: @@ -127,14 +119,10 @@ func _parse_data_size(text: String) -> int: return size +func _queue_raw(command: TrimsockCommand, data_size: int) -> void: + _queued_raw = command + _queued_raw_size = data_size + func _dequeue_raw() -> void: _queued_raw = null _queued_raw_size = -1 - -func _clear_error() -> void: - last_error = OK - last_error_message = "" - -func _set_error(error: Error, message: String) -> void: - last_error = error - last_error_message = message diff --git a/trimsock.gd/addons/trimsock.gd/result.gd b/trimsock.gd/addons/trimsock.gd/result.gd new file mode 100644 index 0000000..6045519 --- /dev/null +++ b/trimsock.gd/addons/trimsock.gd/result.gd @@ -0,0 +1,133 @@ +extends RefCounted +class_name TrimsockResult + +## Represents the result of a trimsock operation +## +## If the call was successful, this instance will store the resulting +## value, and [method is_success] will return [code]true[/code]. This class has +## multiple specializations, each implementing a [code]value()[/code] method +## that returns a type-safe success result. +## [br][br] +## If the call failed, [method is_success] will return [code]false[/code]. The +## actual error is returned by [method error]. + +## Represents an error encountered while ingesting or parsing trimsock data +class ErrorData: + ## The error code + ## [br][br] + ## This indicates the error type. Can be checked programmatically to + ## intelligently try and recover from an error. + var code: Error + + ## The error message + ## [br][br] + ## This gives a further description of the error itself. Should not be + ## relied on, error messages may change between releases without notice. + ## However, they can be useful to convey the issue to the user. + var message: String + + func _init(p_code: Error, p_message: String): + code = p_code + message = p_message + + func _to_string() -> String: + return "%s: %s" % [error_string(code), message] + + +## Stores a [TrimsockCommand] on success +## +## See [TrimsockResult] for details. +class Command extends TrimsockResult: + ## Construct a successful result object with the given [param value] + static func of_value(value: TrimsockCommand) -> Command: + var result := Command.new() + result._is_success = true + result._value = value + return result + + ## Construct a successful result object without any command + ## [br][br] + ## Used when no command could be extracted yet, without any error. + static func of_success() -> Command: + var result := Command.new() + result._is_success = true + return result + + ## Construct an error result + static func of_error(error: Error, message: String) -> Command: + var result := Command.new() + result._is_success = false + result._error = ErrorData.new(error, message) + return result + + ## Get the resulting command + ## [br][br] + ## Returns [code]null[/code] if the operation failed, or if it succeeded + ## without extracting a command. + func value() -> TrimsockCommand: + if _is_success: + return _value as TrimsockCommand + else: + return null + +## Stores binary data on success +## +## See [TrimsockResult] for details. +class Data extends TrimsockResult: + ## Construct a successful result object with the given [param value] + static func of_value(value: PackedByteArray) -> Data: + var result := Data.new() + result._is_success = true + result._value = value + return result + + ## Construct an error result + static func of_error(error: Error, message: String) -> Data: + var result := Data.new() + result._is_success = false + result._error = ErrorData.new(error, message) + return result + + ## Get the resulting data + ## [br][br] + ## Returns an empty array if the operation failed. + func value() -> PackedByteArray: + if _is_success: + return _value as PackedByteArray + else: + return PackedByteArray() + +var _is_success: bool +var _value: Variant +var _error: ErrorData + +## Construct an error result +static func failure(error: Error, message: String) -> TrimsockResult: + var result := TrimsockResult.new() + result._is_success = false + result._error = ErrorData.new(error, message) + return result + +## Construct a successful result without any value +static func success() -> TrimsockResult: + var result := TrimsockResult.new() + result._is_success = true + return result + + +## Return true if the operation was successful +func is_success() -> bool: + return _is_success + +## Return the error, or [code]null[/code] if the operation was successful +func error() -> ErrorData: + if _is_success: + return null + else: + return _error + +func _to_string() -> String: + if _is_success: + return str(_value) + else: + return str(_error) diff --git a/trimsock.gd/addons/trimsock.gd/result.gd.uid b/trimsock.gd/addons/trimsock.gd/result.gd.uid new file mode 100644 index 0000000..d936476 --- /dev/null +++ b/trimsock.gd/addons/trimsock.gd/result.gd.uid @@ -0,0 +1 @@ +uid://b8q6e1o6dw1eh diff --git a/trimsock.gd/tests/conventions.test.gd b/trimsock.gd/tests/conventions.test.gd index 7466954..4754278 100644 --- a/trimsock.gd/tests/conventions.test.gd +++ b/trimsock.gd/tests/conventions.test.gd @@ -39,7 +39,7 @@ func suite(): test("params should passthrough raw", func(): reader.ingest_text("\rcmd 4\n1234\n") - var command := reader.read() + var command := reader.read().value() expect_not_null(command) expect(command.is_raw, "Command must be raw!") expect(not command.text, "Command should have no text!") @@ -53,7 +53,7 @@ func check_type(name: String, input: String, expected_type: TrimsockCommand.Type test(name, func(): reader.ingest_text(input) - var command := reader.read() + var command := reader.read().value() expect_not_null(command, "Command was null!") expect_equal( TrimsockCommand.type_string(command.type), @@ -67,7 +67,7 @@ func check_params(name: String, input: String, expected_params: Array, expected_ test(name, func(): reader.ingest_text(input) - var command := reader.read() + var command := reader.read().value() expect_not_null(command) expect_equal(command.params, expected_params, "Params did not match!") expect_equal(command.kv_pairs.map(func(it): return [it.key, it.value]), expected_pairs, "KV-pairs did not match!") diff --git a/trimsock.gd/tests/reactor.test.gd b/trimsock.gd/tests/reactor.test.gd index 21bad00..51b3f98 100644 --- a/trimsock.gd/tests/reactor.test.gd +++ b/trimsock.gd/tests/reactor.test.gd @@ -101,8 +101,7 @@ func suite(): ) test("should keep parsing after an unsatisfiable raw command", func(): - var errors := [] - reactor.on_ingest_error.connect(func(source, error, message): errors.append(error)) + capture_signal(reactor.on_ingest_error, 3) var commands := [] reactor.on("command", func(cmd, xchg): commands.append(cmd)) @@ -114,6 +113,6 @@ func suite(): reactor.ingest_text(some_source, "command foo\n") reactor.poll() - expect_not_empty(errors, "No errors reported!") + expect_not_empty(get_signal_emissions(reactor.on_ingest_error), "No errors reported!") expect_not_empty(commands, "No commands handled!") ) diff --git a/trimsock.gd/tests/reader.perf.gd b/trimsock.gd/tests/reader.perf.gd index 26b5079..5f51c50 100644 --- a/trimsock.gd/tests/reader.perf.gd +++ b/trimsock.gd/tests/reader.perf.gd @@ -18,7 +18,7 @@ func suite(): test("Parsing", func(): benchmark("Predefined commands", func(__): reader.ingest_text(COMMANDS[idx]) - while reader.read() != null: + while reader.read().value() != null: pass idx = (idx + 1) % COMMANDS.size() )\ diff --git a/trimsock.gd/tests/reader.test.gd b/trimsock.gd/tests/reader.test.gd index a113cb2..614e2d6 100644 --- a/trimsock.gd/tests/reader.test.gd +++ b/trimsock.gd/tests/reader.test.gd @@ -13,7 +13,7 @@ func suite(): test("should read raw message", func(): ok() reader.ingest_text("\rcommand 4\na\ncd\n") - var command := reader.read() + var command := reader.read().value() expect_not_null(command) expect_true(command.is_raw) @@ -23,10 +23,10 @@ func suite(): define("raw data terminator", func(): test("should read raw message split at its terminator", func(): reader.ingest_text("\rcommand 4\n1234") - expect_null(reader.read(), "Command was read without its terminator!") + expect_null(reader.read().value(), "Command was read without its terminator!") reader.ingest_text("\n") - var command := reader.read() + var command := reader.read().value() expect_not_null(command) expect_equal(command.raw, "1234".to_utf8_buffer()) @@ -38,20 +38,21 @@ func suite(): expect_equal(read_names(), ["command", "command"]) ) - test("should reject raw data with a malformed terminator", func(): - reader.ingest_text("\rcommand 4\n1234X\n") + test("should reject raw data with incorrect size", func(): + reader.ingest_text("\rinvalid-command 4\n1234X\n") - expect_null(reader.read(), "Command was read!") - expect_not_equal(reader.last_error, OK, "No error was reported!") + var result := reader.read() + expect_null(result.value(), "Command was read!") + expect_error(result.error(), ERR_PARSE_ERROR) ) test("should keep parsing after a malformed terminator", func(): - # The malformed terminator is consumed in its place, so parsing + # The malformed terminator "X" is consumed in its place, so parsing # resumes on the next line - reader.ingest_text("\rcommand 4\n1234Xcommand foo\n") - expect_null(reader.read(), "Command was read!") + reader.ingest_text("\rinvalid-command 4\n1234Xvalid-command foo\n") + expect_null(reader.read().value(), "Command was read!") - expect_equal(read_names(), ["command"]) + expect_equal(read_names(), ["valid-command"]) ) ) @@ -67,8 +68,9 @@ func suite(): test("should keep parsing after rejecting a raw command", func(): reader.ingest_text("\r\n") - expect_null(reader.read(), "Command was read!") - expect_not_equal(reader.last_error, OK, "No error was reported!") + var result := reader.read() + expect_null(result.value(), "Command was read!") + expect_error(result.error(), ERR_INVALID_DATA) reader.ingest_text("command foo\n") expect_equal(read_names(), ["command"]) @@ -79,10 +81,10 @@ func suite(): # 15 bytes of data plus the terminating newline exactly fill the buffer reader.ingest_text("\rcommand 15\n") - expect_null(reader.read(), "Command was read without data!") + expect_null(reader.read().value(), "Command was read without data!") reader.ingest_text("012345678901234\n") - var command := reader.read() + var command := reader.read().value() expect_not_null(command) expect_equal(command.raw, "012345678901234".to_utf8_buffer()) @@ -93,16 +95,16 @@ func suite(): test("should reject data over max_size", func(): reader.max_size = 8 - expect_not_equal(reader.ingest_text("command foobar\n"), OK) + expect_error(reader.ingest_text("command foobar\n").error(), ERR_OUT_OF_MEMORY) ) test("should resume parsing after discarding the buffer", func(): reader.max_size = 16 reader.ingest_text("012345678901") - expect_null(reader.read(), "Command was read!") + expect_null(reader.read().value(), "Command was read!") - expect_not_equal(reader.ingest_text("01234"), OK, "No overflow!") + expect_error(reader.ingest_text("01234").error(), ERR_OUT_OF_MEMORY) reader.ingest_text("command\n") expect_equal(read_names(), ["command"]) @@ -112,9 +114,9 @@ func suite(): reader.max_size = 16 reader.ingest_text("\"") - expect_null(reader.read(), "Command was read!") + expect_null(reader.read().value(), "Command was read!") - expect_not_equal(reader.ingest_text("0123456789012345"), OK, "No overflow!") + expect_error(reader.ingest_text("0123456789012345").error(), ERR_OUT_OF_MEMORY) reader.ingest_text("command foo\n") expect_equal(read_names(), ["command"]) @@ -124,9 +126,9 @@ func suite(): reader.max_size = 16 reader.ingest_text("\rcommand 8\n") - expect_null(reader.read(), "Command was read without data!") + expect_null(reader.read().value(), "Command was read without data!") - expect_not_equal(reader.ingest_text("0".repeat(17)), OK, "No overflow!") + expect_error(reader.ingest_text("0".repeat(17)).error(), ERR_OUT_OF_MEMORY) reader.ingest_text("command foo\n") expect_equal(read_names(), ["command"]) @@ -137,15 +139,24 @@ func check_invalid_raw(name: String, input: String) -> void: test("should reject raw command with " + name, func(): reader.ingest_text(input) - expect_null(reader.read(), "Command was read!") - expect_not_equal(reader.last_error, OK, "No error was reported!") + var result := reader.read() + expect_null(result.value(), "Command was read!") + expect_error(result.error(), ERR_INVALID_DATA) ) +# Assert that the reported error has the specified error code +func expect_error(error: TrimsockResult.ErrorData, code: Error) -> void: + expect_not_null(error, "No error was reported!") + + if error: + expect_equal(error.code, code, "Reported %s instead of %s!" \ + % [error_string(error.code), error_string(code)]) + # Read all the commands available and return their names func read_names() -> Array: var names := [] while true: - var command := reader.read() + var command := reader.read().value() if not command: break names.append(command.name) diff --git a/trimsock.js/.gitattributes b/trimsock.js/.gitattributes new file mode 100644 index 0000000..8ad74f7 --- /dev/null +++ b/trimsock.js/.gitattributes @@ -0,0 +1,2 @@ +# Normalize EOL for all files that Git considers text files. +* text=auto eol=lf diff --git a/trimsock.js/bun.lockb b/trimsock.js/bun.lockb index 9fcbd89968eef7ded19b221ff359f6a4a3437063..213502f00a1559c8f2e6a908a374404b9c1a7cd0 100644 GIT binary patch delta 2438 zcmbVNdr(wm6u)<$cNbO{dF=w}BCpq)C~QU}Pwd6?PTH5w^7c z5g(lKF_rmfQvN}))j+Yt!O+nnnrSp2qgFPRC9^&%8;d&M?p<5Tai+eRbI*6qcOJiU z?)mQBxq}{fi{6r~6;c#+V{+vmpI2N1iFWApXzRu3nx@JES?Bi)$}5hT)=RcmadWno zX$8xjn8(ja~&HRq#;*hg&tuTO~EOl9@>Q>@Cd`uo1$XK16)!aaH23V z5oF+E(xF8Z?3EaxrRmToQW9U06}p5

L;HUM4nyNID(kG0-Cpd$jk6eUXG-UyH=z zwXjQae3p_hI4$ydc_b=`&~d;?eP~Fx2u2;)r39@@>O}`TTJ%X;P4{Niqb1 z#^a<1<0MOPpk>QAsc)S0V4P%81o+8r;6PaLok!uTAdndF$YsGe2sx<4dGYqnht;m& ztMqdr6Y?rrrlBQGEOChdYYZ7cZRS6;dhkrhCkfo9i8*u%amezz_&8}nE(w5CfJDw` z^W5s*t1SAjF6v2F6m25syw~^lb?!+t-l-i-o6%{zdFTtzN$Tj=>qpsb5%oWvSqqom ztN-%Awen4NLzd0C$u;J@8nb=ds#ej#sNuRjzatEq8k;zVJPFbGmnez5TVRg7`|z~+-?ll9mFcFAyl_xkXI-FZE8Sjdyy-l( z{L{q3(K!e9y??*o5EY*tdVSUWL++E!7xMb(vhMRXIs41r0IVr~pvXmy;m!y_|Jg~# zj>u!5L>V>1^qP9-OFIOMlD~OAq_?X2_PyA=mWO?6d*2VM-6t~dUDK4-cc0iC`NrsG z`@ZF_CD+}zW3LB+GOijX>kLQ`O80QS3|R&rmuG=YZ)_vN*q^>d_S2Ebn-mZMSD@@2 zdejlD#D-kdWPZ=`oJ>PJ67kpbI^strge-!B_)3s)S|TZONFWjAD?BzX6*4(3tkuVa z=c0NRQa%#NL1J>yt&fbwJ3t1RG9%&WQbwcx) zj_8Olkx2tdtjF07kUDU+%z$?cTK8E)mk=CgCC$K7W;H%LENWT+pQWjz1)>zZ?y-1l zwb*U)JJ=oHK3%t=rdkQEG-CV6BU&IK#gp||+h~S1uo*0ATM5R*hP9_pSo0cOzI&X3 z-DdUE3|*OOVlAwJs;_laIjShCK3{n*A#NZAPYf)_8cqL>OjoAi2}i*d^N$h!Uzz{s zB%s$>*bK0n!vd1~|7+(YNgoz*uKNru${>YS$Q>$Ww^TEOsBtXHNlJUW^!fjgrZ_P>I#Cj-_j=oee48 zMBvJehJLFmmOt=?rl?h=O-0RRRe3FZvMw9g3#Y-(LeAvjRXN3e9L+WLD{GqA`87?= zwT%saJ`W2?bec^`Gc{>2lA$7ZoeG5&!gSjIq~k8i?R=O$#h5`0i*20Q!>crN{29hK iXRbG&uQjZX&D2;(BjAE9m042qvUTM%%_Ts`?feVd7&Ht3 delta 2252 zcmb7F3rtgI6#j2}3vFqkyyWdDAY8RvZfUUvQL2Mk0hQq+1TzP^Mja1@*7wwkGrBmn zaLmShOiecTU@W+9#yJ;RGBZHfoXmu58kw8ZDT;CEFebS3_qM_|G~1tiJ@x<5l&fk9MuF!DbUAp}Ip@FzbSsS)>ci(N27t|Le zOa9=Gt+g`33EU7#h%+4J7?iy#LX;?T)PyKd)}choxx{XFtXpX#?mLW?0jo4#6CFM- zcuV5k&dmD`Rlqu^hhF!T40AvfR!>0%4{&|bf8fvL|X<+dSW2AH!IOPhc_cOscKMVB`@2_HH zG!~TkM?#y7qhyP}N`~8s!V_^2O#;Pq`UqPb$3ukqxSBPtmL=g|#YrBD+-LL5Q4O zO}RMrNUl+>8nLVQZ*N>6wVx8`v#*h+fF(%G#}#D8y46a&sti0c#&g=@8LO>GX8nT< z-t8qr(UNwIgr*tXhU_FXrDrGlph3zsxeY4T-J;26Qe~*IAXr2$f?DMvsE--mcPmP| ztnR3gg8#dvKJ+Iw^u-jy39X9CSrbHOo?rHgp%7Md+^ehF^byB2pR#uA}UT8_!&Nx2g1vowv@E#5YV?Rh7b*Uf+GhafRD?eBHdy9-Z9Zwr8+y&B5dC z_xj%&%&%6|e1G;uESe!hK|?5YBeT0v)*m;mcu&%O|H`*baqksN_1n+i%B=YH)orHF zf4SDP>;1NiM~h_9_l!H^>IPr$)eeoD@O#gcBQ0m!vkFTd7OHbWofv5`EOfcrUul+{ zb_)Z#o~ovhAinWv^a`ChHtcrsHRay@bCON%S2N~+d$sqS2VGNjj}ni?Zkm1jJb%Jn zcc4W&!);5v8~{##4xCbjN4k=_A{cX;m};R{DzojiOL*8Q7@~(BiHjQWh_zl5L4d6E zWT@1~hFg%HhM10^S^=SlgZc=q0iTQr>^s^!#Du{AEy+Y++mT$jpf`V-6IC7~l*^#Xg2Mi_gzUj_#c3 z^+K;K6){rp+ta2;1+IOWu+NZ8E!?LKA4r4gSCxJf4~=u`CT_DW7T{-76vM$oQ&bEm zsVsNc><&V5&>p{{PVQuyAWg{$Nq}b5C$Onr;c0X{C>H;gv) zE$!M7ax+1KMH_fMFy>I`%Tng~hLmq4X|OcffrR+RmTyQ&m_-IGI-QVFuj4-#%nuiQ zU5rFH+V8l;zt-|ICR(Eu^pBfrxmMF0b-m!H3;#JiU zoTr7D{K!x*WufENo>prsU*doxd9l!+>lfmAOG_2mRDC9&4u_{}GrU!z!uym;ofxQR zF(*RS%&9G~7nMKT=>0yoSs}kL8CK_~r)g%2Mnb+QM=O9|V5_w+Ur{|Z-U#LS5&oiO q2%XN%@UIbEN!yiWUO%pU46z`o$NMTTc(QxwESv{BaICtLO8x@xRx!T- diff --git a/trimsock.js/packages/trimsock-bun/lib/bun.socket.reactor.ts b/trimsock.js/packages/trimsock-bun/lib/bun.socket.reactor.ts index 0c8d00a..26f4fe3 100644 --- a/trimsock.js/packages/trimsock-bun/lib/bun.socket.reactor.ts +++ b/trimsock.js/packages/trimsock-bun/lib/bun.socket.reactor.ts @@ -66,6 +66,7 @@ export class BunSocketReactor extends Reactor< return { socket: { + ...baseHandlers, data: (socket, data) => { baseHandlers.data?.call(baseHandlers.data, socket, data); this.ingest(data, socket); @@ -82,7 +83,6 @@ export class BunSocketReactor extends Reactor< }, error: (socket, error) => { baseHandlers.error?.call(baseHandlers.error, socket, error); - this.detach(socket); }, }, }; diff --git a/trimsock.js/packages/trimsock-js/lib/reader.ts b/trimsock.js/packages/trimsock-js/lib/reader.ts index 3f1d42d..f6688ee 100644 --- a/trimsock.js/packages/trimsock-js/lib/reader.ts +++ b/trimsock.js/packages/trimsock-js/lib/reader.ts @@ -7,7 +7,7 @@ import { import { BufferOverflowError, ParserError, - UnexpectedCharacterError + UnexpectedCharacterError, } from "./errors.js"; /* @@ -342,9 +342,7 @@ export class TrimsockReader { // The data and the terminating newline must both fit in the buffer if (!Number.isInteger(size) || size < 0 || size >= this.maxSize) - throw new ParserError( - `Invalid raw command size: ${command.text}`, - ); + throw new ParserError(`Invalid raw command size: ${command.text}`); this.queuedRawCommand = command; this.queuedRawSize = size; diff --git a/trimsock.js/packages/trimsock-js/spec/reader.spec.ts b/trimsock.js/packages/trimsock-js/spec/reader.spec.ts index 1591636..5447258 100644 --- a/trimsock.js/packages/trimsock-js/spec/reader.spec.ts +++ b/trimsock.js/packages/trimsock-js/spec/reader.spec.ts @@ -263,7 +263,7 @@ describe("TrimsockReader", () => { ]); }); - test("should reject raw data with a malformed terminator", () => { + test("should reject raw data with incorrect size", () => { const reader = new TrimsockReader(); reader.ingest("\rcommand 4\n1234X\n"); @@ -273,12 +273,14 @@ describe("TrimsockReader", () => { test("should keep parsing after a malformed terminator", () => { const reader = new TrimsockReader(); - // The malformed terminator is consumed in its place, so parsing resumes + // The malformed terminator (X) is consumed in its place, so parsing resumes // on the next line - reader.ingest("\rcommand 4\n1234Xcommand foo\n"); + reader.ingest("\rinvalid-command 4\n1234Xvalid-command foo\n"); expect(() => [...reader.commands()]).toThrow(UnexpectedCharacterError); - expect([...reader.commands()].map((it) => it.name)).toEqual(["command"]); + expect([...reader.commands()].map((it) => it.name)).toEqual([ + "valid-command", + ]); }); }); diff --git a/trimsock.js/packages/trimsock-node/lib/node.socket.reactor.ts b/trimsock.js/packages/trimsock-node/lib/node.socket.reactor.ts index a7c4eaf..25a8e76 100644 --- a/trimsock.js/packages/trimsock-node/lib/node.socket.reactor.ts +++ b/trimsock.js/packages/trimsock-node/lib/node.socket.reactor.ts @@ -33,6 +33,7 @@ export class NodeSocketReactor extends Reactor { .on("connection", (socket: net.Socket) => { socket.on("data", (data: Buffer) => this.ingest(data, socket)); socket.on("close", () => this.detach(socket)); + socket.on("error", (err: Error) => console.error(err)); }); } @@ -54,6 +55,7 @@ export class NodeSocketReactor extends Reactor { const socket = net.createConnection(options, connectionListener); socket.on("data", (data: Buffer) => this.ingest(data, socket)); socket.on("close", () => this.detach(socket)); + socket.on("error", (err: Error) => console.error(err)); return socket; } From eb995d1bf9ebb375dfc7a13b1cba3ca0bed770ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 9 Sep 2026 17:03:27 +0200 Subject: [PATCH 3/5] bv --- trimsock.gd/addons/trimsock.gd/plugin.cfg | 2 +- trimsock.js/bun.lockb | Bin trimsock.js/packages/trimsock-bun/package.json | 2 +- trimsock.js/packages/trimsock-js/package.json | 2 +- trimsock.js/packages/trimsock-node/package.json | 2 +- 5 files changed, 4 insertions(+), 4 deletions(-) mode change 100644 => 100755 trimsock.js/bun.lockb diff --git a/trimsock.gd/addons/trimsock.gd/plugin.cfg b/trimsock.gd/addons/trimsock.gd/plugin.cfg index a768d22..cdd8c22 100644 --- a/trimsock.gd/addons/trimsock.gd/plugin.cfg +++ b/trimsock.gd/addons/trimsock.gd/plugin.cfg @@ -3,5 +3,5 @@ name="trimsock.gd" description="Godot implementation of the trimsock protocol" author="Tamás Gálffy" -version="0.13.2" +version="0.13.3" script="trimsock.gd" diff --git a/trimsock.js/bun.lockb b/trimsock.js/bun.lockb old mode 100644 new mode 100755 diff --git a/trimsock.js/packages/trimsock-bun/package.json b/trimsock.js/packages/trimsock-bun/package.json index 099f1de..61ffe1d 100644 --- a/trimsock.js/packages/trimsock-bun/package.json +++ b/trimsock.js/packages/trimsock-bun/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/trimsock-bun", - "version": "0.19.0", + "version": "0.19.1", "module": "dist/index.js", "type": "module", "author": "Tamás Gálffy", diff --git a/trimsock.js/packages/trimsock-js/package.json b/trimsock.js/packages/trimsock-js/package.json index a6ff895..4db1a99 100644 --- a/trimsock.js/packages/trimsock-js/package.json +++ b/trimsock.js/packages/trimsock-js/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/trimsock-js", - "version": "0.19.0", + "version": "0.19.1", "module": "dist/index.js", "type": "module", "author": "Tamás Gálffy", diff --git a/trimsock.js/packages/trimsock-node/package.json b/trimsock.js/packages/trimsock-node/package.json index 0ac06ae..e61c9ce 100644 --- a/trimsock.js/packages/trimsock-node/package.json +++ b/trimsock.js/packages/trimsock-node/package.json @@ -1,6 +1,6 @@ { "name": "@foxssake/trimsock-node", - "version": "0.19.0", + "version": "0.19.1", "module": "./dist/index.js", "type": "module", "author": "Tamás Gálffy", From 551242c77d291f40a6b8a8b00a110716fd7c481c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tam=C3=A1s=20G=C3=A1lffy?= Date: Wed, 9 Sep 2026 17:06:09 +0200 Subject: [PATCH 4/5] fix pin --- trimsock.js/bun.lockb | Bin 19904 -> 21896 bytes .../packages/trimsock-bun/package.json | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/trimsock.js/bun.lockb b/trimsock.js/bun.lockb index 213502f00a1559c8f2e6a908a374404b9c1a7cd0..376914804c807ca8e6db9f428c04547ae3787910 100755 GIT binary patch delta 1793 zcmb7EdrXs86#u@`@+cH2;KD1g0s|E2n^Gv0$3&oj0#;xMggqP&r$Sm@Y6dbS9Wn>p z>5wfGCviq*W~n4>WMhm#m|}*`xEBks=ulyEx;gm+ap!#fx?r+@cAAs(o!`0lch0@H z=Y07dZ5p6E#T&zV6%P)l{%~-vN?3E?`HZ>~e@DeuoLsIe;pa@fzc>8Bik@Ob*;Nq8 zX^l2FRMQ)W1r+5(QO$Oa6y*%|GFTU|t1Mm!mJj~0e4%>^UrrzCM%50EBWK`bn8m~M?}zarquuHpY_Ll+V#A@j@%fWFK+*3yE*54n%|+D!*A}^KD_iz zo5li1qNz2vHLzm#q-eP2ug4d!SC%E#PBug&b0#~teU@jwo*2`|5vIR zLzA{?z*r&5l}^xI;|A^9J(Ws3?QvdxO;JSeI%3Is;?SH9&EhFz3jbVg4f!xIzu7Cl zLh;*#W;$`cwNh=<+7qiF7zGMRDg3vgtoQA#5c4Lu27NAz;dLU0 zzU^-gSHWQDZ(_e?thEQ54GMXhC^-u{fK32JVg-v~cRUA*#Lz7aA#^&G3W|b9!Nkzr zOdB9EG%$m~Bw#S)jbSmE3RD^>a;O=sn4+MqsV$%=v7kr@-A7?RAu)6{gSoL762lx| zFn6%nkH`_vV(FL-BwkJ8Szi&-2Y}-8Kv298yZ}5eo)77Ctl~G;i0M(W?J))7 zHm$(oK|YR!6hVRr`1A$!0acJa@V! qLXLVesM|`Wcv2$GbbQKohV=2(dh5;#J?zZq53}xVz2#iiGxR?Lf1G~+ delta 585 zcmeBJ&3Iro;{-j;2Xb+LFNWWn%%*ni$h+$6(y2vpuAGxzL4K`L3Ffwl6EXY*PxOoS22@j*`2n2Q5CB;X(#r>0eJ_e2ITw$#y|oHDy%1OvsBys!15^DWK%m^ z#_f|+9k+5_0NDTp0ydLXoO~wdIXQ7T*g%A=yf*K0y2i_8qGzO6l3tZmnm2i)x8meI z0TPq{1d4N2ctMn{@R}?eq&(R^$dKy;RLH?+a#fJu Date: Wed, 9 Sep 2026 17:10:18 +0200 Subject: [PATCH 5/5] fix bun v --- trimsock.js/bun.lockb | Bin 21896 -> 21504 bytes trimsock.js/package.json | 9 +++------ .../packages/trimsock-bun/package.json | 4 ++-- 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/trimsock.js/bun.lockb b/trimsock.js/bun.lockb index 376914804c807ca8e6db9f428c04547ae3787910..a850a2909d9e7c4e2b8ded8138eb7eaaa1bca892 100755 GIT binary patch delta 1364 zcmZ8hZA_b06uxgM9sOXV)Iz~B)&ZUCq@VOlT0$EbZeUglIFp4s1Vj?I6ew)qq@B@7 zh-<(bB$E9w(;qmugzl9UH%7=lknAHvVg?&S`7<$LiN-8ym;uje-x}h{`{dm7oO{nX z@4e^VzHgY&C^MklJDD;v5$^SW+!@LLMCb!ct2$2q6gb=TGZHpCnH%U?rcVj4W0 z%L^7oPWl;DG?fq`A)$5963~qu9f59tGkF7S5!g{y$_p}S+42JE>Aae|_CEs>aW@@g zN0UC3B%5IZLnC}0ol8og9)5~&8kW8Ce>O|7oseQ+W%2xk$LVz1#@9(*;I)7^DdB~P zR^|j9lvXeo*oV>=c}y}}pFYg6XXW-C!F7*7*tZbYbbWp1`Ko*~a@|*#_4$*Tq8qBk zS+&9ZeVOlD8(EEp$QLL22L7nre#v|6$Vac;Oh5HmoxY>%3SkI*8#^{Y7jhX<@vq)` zc3IK(e0M<8YM40aREq0+$?13QKgyfG_Cu}Gtsx`jt;?!tRz_7uw))3R=YvjvPW$a} z+qiAsdv!drJV=ifD1y9GOZ>8TFI>7vo9ExRES62oIUkQNe0A|o|J0$C*2kfV;n917 z^`B~g-!?LG?Yw->G&bDy_np@pKDn`ETvvL2-cp@zYjyXlXZFq*m(m=hDfjw!&m58| z$IgX+n!a2vKXBwx|LtDut+%H>@U>+5nr-LL-K{VWz3iFPtiAB&&CU7JbN2pH70ppA z=oxh-W1tV!hv}kP!aDZ&`Rw$*PlaM;x+tFPshNh&#pN5DaY0fGfU5pGw5@jH$gotL zgEk+4qNh@e%xQoTdXfPeZfxYV0CW`q+rh+f+dw-37^#RhSY$zHvRDM@Qc?~;fp&>; z(4pi203%sBPRwx_$;NTe*6;SVUZIP1!e@U90=`MBC)815$xK}I_nx-g z>q}qT&0`E&a8gx?jD}qzbK(I=-1IcYXTYr@B=IC9?mmz4nY2bNoMMzVyT(PkT(N27 zsEg*itLSmJTQtB?T^{y|`$4J*5@`c1J#Td$r;$n(o%ASV994?1O^2?%xuG#YT~$gt zRxOanEL~xoEi~96o54og4&`QYwSbQs6=EqS@a=+?jiL!pyp~@*G{>8Vn?g&xMv=Zc jPKUf~k2lN+$GmZSg(|)I)Ld&4>AAP@Xbsz5o5B1Gf;CNd delta 1635 zcmZuxdrX^E6#u@`@+fqy0}O=*=r#(h&{F7&cZ)$-d6f>?WI#YOdBqLV4#rsIA(DT!LP1=`EX6|(}a^gr&e?N z#;)D%sk>4!UvBGhYVq(YS1J+*TQbYE*od&a%B_pjv*b(iZD%7?KdUyo_M!ZSY}*_zAb`|Gz}b*r$9 zB}$5n7XA7Ml7hYc<7DWdZ$rmV0o|=dY3~&CZ_SIsms1P>em>K(nRlJ=nm;QN-utKa zlAtY@^Y4WR!%GEizaFs~hEn~Tej7bsnfT<+53O-FkVaG6p4;H1%qGyWt+yvu?w1!Q zot`=)(XpmFv%fJ~?uvl!LuKSRc}lybWP6(Ooyye)18HJOGtmOHJk9nzGOByosJ>` z@liV3RxPe)lbcT7=^P90K9(|PQW6_vcjtY&l*`L02_cN10aWU$8UzaMh@qPn6aGk&fqfvSUHN}T)-y) zux>QN;m;Wiz`F3WV!Z}zH7W&whWc?4XfH|+z{1c$b`Dz6&S7CS!$Dh8sQ|2@VK`_j z>J_`?7!63=jHxEfeI;j2T4P<1sQ%Lf23 zI}m{9f&0h(;{LE2%17WBCF1gkGytxEyaQ#Z9qfx3@>IE>2bw59fB+CpQq(K7oJ432 zsPGE#y6}2J0eIBtQ376t<2yX^CEPlgUovUdh>@{f*W7ky%cXJ+VJE0c9j{#ddg-iX z-`ai_4I!xluEp`rIUX_={kBG`l*%J1$BXEA@*olwDV0Y_HI6sZ@d!dhDUDJ|6-1)- zr$>pGc7$7%Y*$3xNG9{z3=*W%a+es&$!M$4J>a>cI;pwz?CYmbk`ld$RA&3~Q514o zb6wR5ldRT69_xi9N$=%sUs_IrvVzD&meAS0ppuASnxuE;)tY1xkR4&y4oMGuIEs4wk diff --git a/trimsock.js/package.json b/trimsock.js/package.json index 010dda2..0b5d32b 100644 --- a/trimsock.js/package.json +++ b/trimsock.js/package.json @@ -1,7 +1,7 @@ { "name": "trimsock", "private": true, - "version": "0.19.0", + "version": "0.19.1", "type": "module", "scripts": { "docs": "typedoc", @@ -10,12 +10,9 @@ "build": "bun --filter '*' build", "perf": "bun --filter '*' perf" }, - "workspaces": [ - "packages/*", - "examples/*" - ], + "workspaces": ["packages/*", "examples/*"], "devDependencies": { "typedoc": "^0.28.8", "typedoc-plugin-markdown": "^4.8.0" } -} \ No newline at end of file +} diff --git a/trimsock.js/packages/trimsock-bun/package.json b/trimsock.js/packages/trimsock-bun/package.json index cf94935..9506067 100644 --- a/trimsock.js/packages/trimsock-bun/package.json +++ b/trimsock.js/packages/trimsock-bun/package.json @@ -16,7 +16,7 @@ }, "devDependencies": { "@biomejs/biome": "1.9.4", - "@types/bun": "1.1.18" + "@types/bun": "1.3.0" }, "peerDependencies": { "typescript": "^5.0.0" @@ -24,6 +24,6 @@ "files": ["./dist/"], "exports": "./dist/index.js", "engines": { - "bun": "1.1.34" + "bun": "1.3.0" } }