History

Fix multi-connection protocol corruption in the dev-db PGlite socket

With the dev-db now accepting concurrent connections, three e2e specs kept failing individually (one per shard, through all retries): policies-ui, mcp-catalog-sync-ui, and oauth-callback-org-scope. Their failure artifacts all showed the same server-side signature: random 500s on /api/tools, /api/connections, /api/oauth/clients ("Failed to load tools" in the UI), backed by PostgresError: bind message supplies N parameters, but prepared statement "" requires M. Root cause: pglite-socket 0.1.4's QueryQueueManager enqueues each postgres wire FRAME (Parse, Bind, Execute, Sync) as its own queue entry against the single shared PGlite session. Two concurrent connections' extended-protocol pipelines interleave: A's Parse (5 params), B's Parse (1 param), then A's Bind hits B's unnamed statement. Whichever request loses the race 500s. A standalone repro (6 clients alternating 1-param and 5-param unprepared queries, the exact drizzle/postgres-js shape) corrupted 34.7% of queries. Patch (patches/@electric-sql%2Fpglite-socket@0.1.4.patch): - batch all frames parsed from one socket data event into ONE queue entry, so a client's Parse..Sync executes atomically under runExclusive; - add pipeline affinity: while a handler's batch ended mid-pipeline (last frame not Sync/Query/Terminate), only that handler's queue entries are scheduled, mirroring the existing transaction affinity; released on detach with a synthesized Sync so an abandoned pipeline can't wedge the queue. Same repro after the patch: 0% corrupted (106k queries clean). Regression test: apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts (fails on stock 0.1.4, verified). Also fixes the remaining race in cloud/oauth-callback-org-scope.test.ts, which had a second, spec-local bug: it deep-linked to ?addAccount=1 while the session cookie still pointed at org B, so the URL bounced (org A -> org B -> org A via OrgSlugGate canonicalization) while the auto-opened modal fetched /api/oauth/clients under the transient org-B scope, got an empty app list, and left "Connect with OAuth" disabled forever. The spec now lands on the integration page without the deep link, waits for org A's shell to settle, opens the modal explicitly, and waits for the button to be enabled before clicking. The pre-armed waitForRequest is also marked handled so a failed click can no longer leak it as an unhandled rejection. All three specs pass together locally against the patched stack.

Rhys Sullivan committed 4 months ago

Commit 57513ff1 parent df86ec56 files · +151 −6Browse files

Unified · Split

6 files
  1. apps/cloud/scripts/dev-db.ts +11 −4
  2. apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts +94 −0
  3. bun.lock +1 −0
  4. e2e/cloud/oauth-callback-org-scope.test.ts +30 −1
  5. package.json +2 −1
  6. patches/@electric-sql%2Fpglite-socket@0.1.4.patch +13 −0
apps/cloud/scripts/dev-db.ts+11−4
@@ -95,10 +95,17 @@ await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); · 94 unmodified lines
9595 // thousands of half-closed sockets, starved real queries, drove request
9696 // latency into the tens of seconds, and eventually hung the stack: the CI e2e
9797 // "cloud dev stack degrades after minutes of sustained load" cascade flake.
98−// PGlite still runs queries serially (its internal QueryQueueManager executes
99−// each under `runExclusive`), so allowing many connections is safe: they queue
100−// instead of being rejected. Raise the cap so concurrent requests wait their
101−// turn instead of storming.
98+// PGlite runs queries serially (its internal QueryQueueManager executes each
99+// under `runExclusive`), so allowing many connections means they queue instead
100+// of being rejected. One caveat makes that safe: stock pglite-socket 0.1.4
101+// enqueues each wire FRAME separately, so two connections' extended-protocol
102+// pipelines (Parse/Bind/Execute/Sync) would interleave inside the one shared
103+// PGlite session and corrupt each other ("bind message supplies N parameters,
104+// but prepared statement requires M" -> random 500s on whichever request lost
105+// the race). The patch in patches/@electric-sql%2Fpglite-socket@0.1.4.patch
106+// batches each socket data event into one queue entry and holds handler
107+// affinity while a pipeline is open;
108+// src/db/dev-db-socket-concurrency.node.test.ts is the regression test.
102109 const server = new PGLiteSocketServer({
103110 db,
104111 port: PORT,
apps/cloud/src/db/dev-db-socket-concurrency.node.test.ts+94−0
@@ -0,0 +1,94 @@
1+// Regression for the dev-db PGlite socket protocol-interleaving bug (patched
2+// in patches/@electric-sql%2Fpglite-socket@0.1.4.patch).
3+//
4+// PGLiteSocketServer's QueryQueueManager used to enqueue each postgres wire
5+// FRAME (Parse, Bind, Execute, Sync) as its own queue entry against the one
6+// shared PGlite session. With more than one connection (the dev-db now allows
7+// many — see scripts/dev-db.ts maxConnections), two clients' extended-protocol
8+// pipelines interleaved: client A's Parse (5 params) ... client B's Parse
9+// (1 param) ... A's Bind now hits B's unnamed statement:
10+//
11+// PostgresError: bind message supplies 5 parameters, but prepared
12+// statement "" requires 1
13+//
14+// which surfaced in e2e as random 500s ("Failed to load tools", StorageError)
15+// on whichever request lost the race — the residual per-spec CI flakes after
16+// the connection-storm fix. The patch batches all frames of one socket data
17+// event into a single queue entry and adds handler affinity while a pipeline
18+// is open, so one client's Parse..Sync executes atomically.
19+//
20+// This test drives concurrent clients issuing unprepared parameterized queries
21+// with DIFFERENT parameter counts (the exact drizzle/postgres-js shape) through
22+// one PGLiteSocketServer and asserts zero protocol corruption.
23+
24+import { describe, expect, it } from "@effect/vitest";
25+import { PGlite } from "@electric-sql/pglite";
26+import { PGLiteSocketServer } from "@electric-sql/pglite-socket";
27+import postgres from "postgres";
28+
29+const PORT = 45998;
30+const CLIENTS = 6;
31+const QUERIES_PER_CLIENT = 40;
32+
33+describe("dev-db PGlite socket under concurrent connections", () => {
34+ it(
35+ "serves interleaved multi-connection pipelines without protocol corruption",
36+ { timeout: 60_000 },
37+ async () => {
38+ const db = await PGlite.create();
39+ const server = new PGLiteSocketServer({
40+ db,
41+ port: PORT,
42+ host: "127.0.0.1",
43+ maxConnections: 100,
44+ });
45+ await server.start();
46+
47+ let ok = 0;
48+ const errors: string[] = [];
49+
50+ const worker = async (id: number) => {
51+ const sql = postgres(`postgres://postgres:postgres@127.0.0.1:${PORT}/postgres`, {
52+ max: 1,
53+ idle_timeout: 0,
54+ connect_timeout: 10,
55+ fetch_types: false,
56+ prepare: true,
57+ onnotice: () => undefined,
58+ });
59+ // oxlint-disable-next-line executor/no-try-catch-or-throw -- test boundary: postgres.js is promise-native and the socket must be closed on every path
60+ try {
61+ for (let q = 0; q < QUERIES_PER_CLIENT; q++) {
62+ // Alternate 1-param and 5-param unprepared queries: maximally
63+ // collision-prone unnamed-statement shapes across connections.
64+ if ((id + q) % 2 === 0) {
65+ await sql.unsafe(`select $1::int as one`, [1]);
66+ } else {
67+ await sql.unsafe(`select $1::int, $2::text, $3::text, $4::text, $5::text`, [
68+ 1,
69+ "b",
70+ "c",
71+ "d",
72+ "e",
73+ ]);
74+ }
75+ ok++;
76+ }
77+ } catch (cause) {
78+ // oxlint-disable-next-line executor/no-unknown-error-message -- test boundary: the raw PostgresError message IS the assertion payload
79+ errors.push(String(cause));
80+ } finally {
81+ // oxlint-disable-next-line executor/no-promise-catch -- test boundary: postgres.js is promise-native; a failed teardown must not mask the assertion
82+ await sql.end({ timeout: 5 }).catch(() => {});
83+ }
84+ };
85+
86+ await Promise.all(Array.from({ length: CLIENTS }, (_, i) => worker(i)));
87+ await server.stop();
88+ await db.close();
89+
90+ expect(errors, `protocol corruption under concurrency:\n${errors.join("\n")}`).toEqual([]);
91+ expect(ok).toBe(CLIENTS * QUERIES_PER_CLIENT);
92+ },
93+ );
94+});
bun.lock+1−0
@@ -1212,6 +1212,7 @@ · 1211 unmodified lines
12121212 },
12131213 },
12141214 "patchedDependencies": {
1215+ "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch",
12151216 "libsql@0.5.29": "patches/libsql@0.5.29.patch",
12161217 "agents@0.17.3": "patches/agents@0.17.3.patch",
12171218 "postgres@3.4.9": "patches/postgres@3.4.9.patch",
e2e/cloud/oauth-callback-org-scope.test.ts+30−1
@@ -223,12 +223,35 @@ scenario( · 222 unmodified lines
223223 });
224224
225225 await step("Start OAuth from the original organization's add-connection flow", async () => {
226− await page.goto(`/${orgA.slug}/integrations/${String(integration)}?addAccount=1`, {
226+ // Land WITHOUT ?addAccount=1 and let the org scope settle first. The
227+ // session cookie still points at org B, so this navigation bounces:
228+ // first paint under the cookie's org B, OrgSlugGate canonicalizes,
229+ // then the URL-scoped /account/me settles auth back on org A. A modal
230+ // auto-opened by the deep link would fire its /api/oauth/clients fetch
231+ // DURING that transient org-B scope, get org B's empty app list, never
232+ // refetch, and leave "Connect with OAuth" disabled forever (the flake
233+ // this step used to have). Waiting for org A's shell before opening
234+ // the modal makes every modal fetch run under the settled org A scope.
235+ await page.goto(`/${orgA.slug}/integrations/${String(integration)}`, {
227236 waitUntil: "networkidle",
228237 });
238+ await page.getByRole("button", { name: new RegExp(escapeRegExp(orgA.name)) }).waitFor({
239+ timeout: 30_000,
240+ });
241+ await page.waitForURL((url) => url.pathname.startsWith(`/${orgA.slug}/`), {
242+ timeout: 30_000,
243+ });
244+ await page.getByRole("button", { name: "Add connection" }).click();
229245 await page.getByRole("heading", { name: /Add connection/ }).waitFor({
230246 timeout: 30_000,
231247 });
248+ // The footer button enables once the registered OAuth app has loaded
249+ // and is selected; wait for that state instead of racing the click
250+ // against the apps fetch.
251+ await page
252+ .getByRole("button", { name: "Connect with OAuth", disabled: false })
253+ .waitFor({ timeout: 30_000 });
254+ // Armed BEFORE the click so the navigation can never outrun the wait.
232255 const authorizeRequest = page.waitForRequest(
233256 (request) => {
234257 const url = new URL(request.url());
@@ -236,6 +259,12 @@ scenario( · 1 unmodified line
236259 },
237260 { timeout: 30_000 },
238261 );
262+ // If the click throws first (e.g. the button never enables), the armed
263+ // wait would otherwise time out later as an UNHANDLED rejection and
264+ // pollute the run with phantom errors. Mark it handled here; the
265+ // `await authorizeRequest` below still surfaces its failure on the
266+ // success path.
267+ authorizeRequest.catch(() => {});
239268 await page.getByRole("button", { name: "Connect with OAuth" }).click();
240269 providerAuthorizeUrl = new URL((await authorizeRequest).url());
241270 await page.waitForURL(
package.json+2−1
@@ -132,6 +132,7 @@ · 131 unmodified lines
132132 "postgres@3.4.9": "patches/postgres@3.4.9.patch",
133133 "@cloudflare/vite-plugin@1.31.2": "patches/@cloudflare%2Fvite-plugin@1.31.2.patch",
134134 "agents@0.17.3": "patches/agents@0.17.3.patch",
135− "libsql@0.5.29": "patches/libsql@0.5.29.patch"
135+ "libsql@0.5.29": "patches/libsql@0.5.29.patch",
136+ "@electric-sql/pglite-socket@0.1.4": "patches/@electric-sql%2Fpglite-socket@0.1.4.patch"
136137 }
137138 }
patches/@electric-sql%2Fpglite-socket@0.1.4.patch+13−0
@@ -0,0 +1,13 @@
1+diff --git a/node_modules/@electric-sql/pglite-socket/.bun-tag-eaa11f63ffd98a26 b/.bun-tag-eaa11f63ffd98a26
2+new file mode 100644
3+index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
4+diff --git a/dist/chunk-NSUMFCRM.js b/dist/chunk-NSUMFCRM.js
5+index 37d45ebc5150c43c919fbdb1c7fffb51b18fda8c..dd6cd0fb7ab26b3911778ddc427a02d7c4d6ebd3 100644
6+--- a/dist/chunk-NSUMFCRM.js
7++++ b/dist/chunk-NSUMFCRM.js
8+@@ -1,3 +1,3 @@
9+-import{createServer as m}from"net";var b=6e4,c=class{constructor(s,e=!1){this.queue=[];this.processing=!1;this.lastHandlerId=null;this.db=s,this.debug=e}log(s,...e){this.debug&&console.log(`[QueryQueueManager] ${s}`,...e)}async enqueue(s,e,i){return new Promise((t,r)=>{let o={handlerId:s,message:e,resolve:t,reject:r,timestamp:Date.now(),onData:i};this.queue.push(o),this.log(`enqueued query from handler #${s}, queue size: ${this.queue.length}`),this.processing||this.processQueue()})}async processQueue(){if(!(this.processing||this.queue.length===0)){for(this.processing=!0;this.queue.length>0;){let s;if(this.db.isInTransaction()&&this.lastHandlerId){let t=this.queue.findIndex(r=>r.handlerId===this.lastHandlerId);t===-1?(this.log("transaction started, but no query from the same handler id found in queue",this.lastHandlerId),s=null):s=this.queue.splice(t,1)[0]}else s=this.queue.shift();if(!s)break;let e=Date.now()-s.timestamp;this.log(`processing query from handler #${s.handlerId} (waited ${e}ms)`);let i=0;try{await this.db.runExclusive(async()=>await this.db.execProtocolRawStream(s.message,{onRawData:t=>{i+=t.length,s.onData(t)}}))}catch(t){this.log(`query from handler #${s.handlerId} failed:`,t),s.reject(t);return}this.log(`query from handler #${s.handlerId} completed, ${i} bytes`),this.lastHandlerId=s.handlerId,s.resolve(i)}this.processing=!1,this.log("queue processing complete, queue length is",this.queue.length)}}getQueueLength(){return this.queue.length}clearQueueForHandler(s){let e=this.queue.length;this.queue=this.queue.filter(t=>t.handlerId===s?(t.reject(new Error("Handler disconnected")),!1):!0);let i=e-this.queue.length;i>0&&this.log(`cleared ${i} queries for handler #${s}`)}async clearTransactionIfNeeded(s){this.db.isInTransaction()&&this.lastHandlerId===s&&(await this.db.exec("ROLLBACK"),this.lastHandlerId=null,await this.processQueue())}},l=class l extends EventTarget{constructor(e){super();this.socket=null;this.active=!1;this.messageBuffer=Buffer.alloc(0);this.lastActivityTime=Date.now();this.queryQueue=e.queryQueue,this.closeOnDetach=e.closeOnDetach??!1,this.inspect=e.inspect??!1,this.debug=e.debug??!1,this.idleTimeout=e.idleTimeout??0,this.id=l.nextHandlerId++,this.log("constructor: created new handler")}get handlerId(){return this.id}log(e,...i){this.debug&&console.log(`[PGLiteSocketHandler#${this.id}] ${e}`,...i)}async attach(e){if(this.log(`attach: attaching socket from ${e.remoteAddress}:${e.remotePort}`),this.socket)throw new Error("Socket already attached");return this.socket=e,this.active=!0,this.lastActivityTime=Date.now(),e.setNoDelay(!0),this.idleTimeout>0&&this.resetIdleTimer(),this.log("attach: setting up socket event handlers"),e.on("data",i=>{this.lastActivityTime=Date.now(),this.resetIdleTimer(),setImmediate(async()=>{try{await this.handleData(i)}catch(t){this.log("socket on data error: ",t),this.handleError(t)}})}),e.on("error",i=>{setImmediate(()=>this.handleError(i))}),e.on("close",()=>{setImmediate(()=>this.handleClose())}),this.log("attach: socket handler ready"),this}resetIdleTimer(){this.idleTimeout<=0||(this.idleTimer&&clearTimeout(this.idleTimer),this.idleTimer=setTimeout(()=>{let e=Date.now()-this.lastActivityTime;this.log(`idle timeout after ${e}ms`),this.handleError(new Error("Idle timeout"))},this.idleTimeout))}async detach(e){if(this.log(`detach: detaching socket, close=${e??this.closeOnDetach}`),this.idleTimer&&(clearTimeout(this.idleTimer),this.idleTimer=void 0),this.queryQueue.clearQueueForHandler(this.id),await this.queryQueue.clearTransactionIfNeeded(this.id),!this.socket)return this.log("detach: no socket attached, nothing to do"),this;if(this.socket.removeAllListeners("data"),this.socket.removeAllListeners("error"),this.socket.removeAllListeners("close"),(e??this.closeOnDetach)&&this.socket.writable){this.log("detach: closing socket");try{this.socket.end(),this.socket.destroy()}catch(i){this.log("detach: error closing socket:",i)}}return this.socket=null,this.active=!1,this.messageBuffer=Buffer.alloc(0),this.log("detach: handler cleaned up"),this}get isAttached(){return this.socket!==null}async handleData(e){if(!this.socket||!this.active)return this.log("handleData: no active socket, ignoring data"),0;this.log(`handleData: received ${e.length} bytes`),this.messageBuffer=Buffer.concat([this.messageBuffer,e]),this.inspectData("incoming",e);try{let i=0;for(;this.messageBuffer.length>0;){let t=0,r=!1;if(this.messageBuffer.length>=4){let n=this.messageBuffer.readInt32BE(0);if(this.messageBuffer.length>=8){let a=this.messageBuffer.readInt32BE(4);(a===196608||a===196608)&&(t=n,r=this.messageBuffer.length>=t)}!r&&this.messageBuffer.length>=5&&(t=1+this.messageBuffer.readInt32BE(1),r=this.messageBuffer.length>=t)}if(!r||t===0){this.log(`handleData: incomplete message, buffering ${this.messageBuffer.length} bytes`);break}let o=this.messageBuffer.slice(0,t);if(this.messageBuffer=this.messageBuffer.slice(t),this.log(`handleData: processing message of ${o.length} bytes`),!this.active||!this.socket){this.log("handleData: socket no longer active, stopping processing");break}let h;if(await this.queryQueue.enqueue(this.id,new Uint8Array(o),n=>{this.log(`handleData: received ${n.length} bytes from PGlite`),this.inspectData("outgoing",n),n.length>0&&this.socket&&this.socket.writable&&this.active&&(this.log("handleData: writing response to socket"),this.socket?.writable?this.socket.write(Buffer.from(n),a=>{a?(this.log("handleData: error writing to socket:",a),h=a):this.log(`handleData: socket sent: ${n.length} bytes`)}):this.log("handleData: socket no longer writable")),i+=n.length}),h)throw h}return this.dispatchEvent(new CustomEvent("data",{detail:{incoming:e.length,outgoing:i}})),i}catch(i){throw this.log("handleData: error processing data:",i),i}}handleError(e){if(!this.active){this.log("handleError: handler not active, ignoring error");return}e.message?.includes("ECONNRESET")?this.log("handleError: client disconnected (ECONNRESET) - normal behavior"):e.message?.includes("Idle timeout")?this.log("handleError: connection idle timeout"):this.log("handleError:",e),this.active=!1,this.dispatchEvent(new CustomEvent("error",{detail:e})),this.detach(!0)}handleClose(){this.log("handleClose: socket closed"),this.active=!1,this.dispatchEvent(new CustomEvent("close")),this.detach(!1)}inspectData(e,i){if(this.inspect){console.log("-".repeat(75)),console.log(e==="incoming"?"-> incoming":"<- outgoing",i.length,"bytes");for(let t=0;t<i.length;t+=16){let r=Math.min(16,i.length-t),o="";for(let n=0;n<16;n++)if(n<r){let a=i[t+n];o+=a.toString(16).padStart(2,"0")+" "}else o+=" ";let h="";for(let n=0;n<r;n++){let a=i[t+n];h+=a>=32&&a<=126?String.fromCharCode(a):"."}console.log(`${t.toString(16).padStart(8,"0")} ${o} ${h}`)}}}};l.nextHandlerId=1;var d=l,u=class extends EventTarget{constructor(e){super();this.server=null;this.active=!1;this.handlers=new Set;this.db=e.db,e.path?this.path=e.path:(typeof e.port=="number"?this.port=e.port??e.port:this.port=5432,this.host=e.host||"127.0.0.1"),this.inspect=e.inspect??!1,this.debug=e.debug??!1,this.idleTimeout=e.idleTimeout??0,this.maxConnections=e.maxConnections??1,this.queryQueue=new c(this.db,this.debug),this.log(`constructor: created server on ${this.getServerConn()}`),this.log(`constructor: max connections: ${this.maxConnections}`),this.idleTimeout>0&&this.log(`constructor: idle timeout: ${this.idleTimeout}ms`)}log(e,...i){this.debug&&console.log(`[PGLiteSocketServer] ${e}`,...i)}async start(){if(this.log(`start: starting server on ${this.getServerConn()}`),this.server)throw new Error("Socket server already started");return await this.db.waitReady,this.active=!0,this.server=m(e=>{setImmediate(()=>this.handleConnection(e))}),this.server.maxConnections=this.maxConnections,new Promise((e,i)=>{if(!this.server)return i(new Error("Server not initialized"));if(this.server.on("error",t=>{this.log("start: server error:",t),this.dispatchEvent(new CustomEvent("error",{detail:t})),this.active||i(t)}),this.path)this.server.listen(this.path,()=>{this.log(`start: server listening on ${this.getServerConn()}`),this.dispatchEvent(new CustomEvent("listening",{detail:{path:this.path}})),e()});else{let t=this.server;t.listen(this.port,this.host,()=>{let r=t.address();if(r===null||typeof r!="object")throw Error("Expected address info");this.port=r.port,this.log(`start: server listening on ${this.getServerConn()}`),this.dispatchEvent(new CustomEvent("listening",{detail:{port:this.port,host:this.host}})),e()})}})}getServerConn(){return this.path?this.path:`${this.host}:${this.port}`}async stop(){this.log("stop: stopping server"),this.active=!1,this.log(`stop: detaching ${this.handlers.size} handlers`);for(let e of this.handlers)e.detach(!0);return this.handlers.clear(),this.server?new Promise(e=>{if(!this.server)return e();this.server.close(()=>{this.log("stop: server closed"),this.server=null,this.dispatchEvent(new CustomEvent("close")),e()})}):(this.log("stop: server not running, nothing to do"),Promise.resolve())}async handleConnection(e){let i={clientAddress:e.remoteAddress||"unknown",clientPort:e.remotePort||0};if(this.log(`handleConnection: new connection from ${i.clientAddress}:${i.clientPort}`),this.log(`handleConnection: active connections: ${this.handlers.size}, queued queries: ${this.queryQueue.getQueueLength()}`),!this.active){this.log("handleConnection: server not active, closing connection");try{e.end()}catch(r){this.log("handleConnection: error closing socket:",r)}return}if(this.handlers.size>=this.maxConnections){this.log("handleConnection: max connections reached, rejecting"),e.write(Buffer.from(`Too many connections
10++import{createServer as m}from"net";var b=6e4,c=class{constructor(s,e=!1){this.queue=[];this.processing=!1;this.lastHandlerId=null;this.pipelineHandlerId=null;this.db=s,this.debug=e}log(s,...e){this.debug&&console.log(`[QueryQueueManager] ${s}`,...e)}async enqueue(s,e,i,S=!0){return new Promise((t,r)=>{let o={handlerId:s,message:e,resolve:t,reject:r,timestamp:Date.now(),onData:i,closes:S};this.queue.push(o),this.log(`enqueued query from handler #${s}, queue size: ${this.queue.length}`),this.processing||this.processQueue()})}async processQueue(){if(!(this.processing||this.queue.length===0)){for(this.processing=!0;this.queue.length>0;){let s;let __affine=this.db.isInTransaction()&&this.lastHandlerId?this.lastHandlerId:this.pipelineHandlerId;if(__affine){let t=this.queue.findIndex(r=>r.handlerId===__affine);t===-1?(this.log("affinity held, waiting for handler",__affine),s=null):s=this.queue.splice(t,1)[0]}else s=this.queue.shift();if(!s)break;let e=Date.now()-s.timestamp;this.log(`processing query from handler #${s.handlerId} (waited ${e}ms)`);let i=0;try{await this.db.runExclusive(async()=>await this.db.execProtocolRawStream(s.message,{onRawData:t=>{i+=t.length,s.onData(t)}}))}catch(t){this.log(`query from handler #${s.handlerId} failed:`,t),s.reject(t);return}this.log(`query from handler #${s.handlerId} completed, ${i} bytes`),this.lastHandlerId=s.handlerId,this.pipelineHandlerId=s.closes?null:s.handlerId,s.resolve(i)}this.processing=!1,this.log("queue processing complete, queue length is",this.queue.length)}}getQueueLength(){return this.queue.length}clearQueueForHandler(s){let e=this.queue.length;this.queue=this.queue.filter(t=>t.handlerId===s?(t.reject(new Error("Handler disconnected")),!1):!0);let i=e-this.queue.length;i>0&&this.log(`cleared ${i} queries for handler #${s}`)}async clearPipelineIfNeeded(s){this.pipelineHandlerId===s&&(this.pipelineHandlerId=null,await this.db.runExclusive(async()=>await this.db.execProtocolRawStream(new Uint8Array([83,0,0,0,4]),{onRawData:()=>{}})).catch(()=>{}),this.processQueue())}async clearTransactionIfNeeded(s){this.db.isInTransaction()&&this.lastHandlerId===s&&(await this.db.exec("ROLLBACK"),this.lastHandlerId=null,await this.processQueue())}},l=class l extends EventTarget{constructor(e){super();this.socket=null;this.active=!1;this.messageBuffer=Buffer.alloc(0);this.lastActivityTime=Date.now();this.queryQueue=e.queryQueue,this.closeOnDetach=e.closeOnDetach??!1,this.inspect=e.inspect??!1,this.debug=e.debug??!1,this.idleTimeout=e.idleTimeout??0,this.id=l.nextHandlerId++,this.log("constructor: created new handler")}get handlerId(){return this.id}log(e,...i){this.debug&&console.log(`[PGLiteSocketHandler#${this.id}] ${e}`,...i)}async attach(e){if(this.log(`attach: attaching socket from ${e.remoteAddress}:${e.remotePort}`),this.socket)throw new Error("Socket already attached");return this.socket=e,this.active=!0,this.lastActivityTime=Date.now(),e.setNoDelay(!0),this.idleTimeout>0&&this.resetIdleTimer(),this.log("attach: setting up socket event handlers"),e.on("data",i=>{this.lastActivityTime=Date.now(),this.resetIdleTimer(),setImmediate(async()=>{try{await this.handleData(i)}catch(t){this.log("socket on data error: ",t),this.handleError(t)}})}),e.on("error",i=>{setImmediate(()=>this.handleError(i))}),e.on("close",()=>{setImmediate(()=>this.handleClose())}),this.log("attach: socket handler ready"),this}resetIdleTimer(){this.idleTimeout<=0||(this.idleTimer&&clearTimeout(this.idleTimer),this.idleTimer=setTimeout(()=>{let e=Date.now()-this.lastActivityTime;this.log(`idle timeout after ${e}ms`),this.handleError(new Error("Idle timeout"))},this.idleTimeout))}async detach(e){if(this.log(`detach: detaching socket, close=${e??this.closeOnDetach}`),this.idleTimer&&(clearTimeout(this.idleTimer),this.idleTimer=void 0),this.queryQueue.clearQueueForHandler(this.id),await this.queryQueue.clearTransactionIfNeeded(this.id),await this.queryQueue.clearPipelineIfNeeded(this.id),!this.socket)return this.log("detach: no socket attached, nothing to do"),this;if(this.socket.removeAllListeners("data"),this.socket.removeAllListeners("error"),this.socket.removeAllListeners("close"),(e??this.closeOnDetach)&&this.socket.writable){this.log("detach: closing socket");try{this.socket.end(),this.socket.destroy()}catch(i){this.log("detach: error closing socket:",i)}}return this.socket=null,this.active=!1,this.messageBuffer=Buffer.alloc(0),this.log("detach: handler cleaned up"),this}get isAttached(){return this.socket!==null}async handleData(e){if(!this.socket||!this.active)return this.log("handleData: no active socket, ignoring data"),0;this.log(`handleData: received ${e.length} bytes`),this.messageBuffer=Buffer.concat([this.messageBuffer,e]),this.inspectData("incoming",e);try{let i=0;const __frames=[];for(;this.messageBuffer.length>0;){let t=0,r=!1;if(this.messageBuffer.length>=4){let n=this.messageBuffer.readInt32BE(0);if(this.messageBuffer.length>=8){let a=this.messageBuffer.readInt32BE(4);(a===196608||a===196608)&&(t=n,r=this.messageBuffer.length>=t)}!r&&this.messageBuffer.length>=5&&(t=1+this.messageBuffer.readInt32BE(1),r=this.messageBuffer.length>=t)}if(!r||t===0){this.log(`handleData: incomplete message, buffering ${this.messageBuffer.length} bytes`);break}let o=this.messageBuffer.slice(0,t);if(this.messageBuffer=this.messageBuffer.slice(t),this.log(`handleData: processing message of ${o.length} bytes`),!this.active||!this.socket){this.log("handleData: socket no longer active, stopping processing");break}__frames.push(o)}if(__frames.length===0)return this.dispatchEvent(new CustomEvent("data",{detail:{incoming:e.length,outgoing:i}})),i;{let o=__frames.length===1?__frames[0]:Buffer.concat(__frames);const __lastF=__frames[__frames.length-1];const __lt=__lastF[0]>=65?__lastF[0]:null;const __closes=__lt===null||__lt===83||__lt===81||__lt===88;let h;if(await this.queryQueue.enqueue(this.id,new Uint8Array(o),n=>{this.log(`handleData: received ${n.length} bytes from PGlite`),this.inspectData("outgoing",n),n.length>0&&this.socket&&this.socket.writable&&this.active&&(this.log("handleData: writing response to socket"),this.socket?.writable?this.socket.write(Buffer.from(n),a=>{a?(this.log("handleData: error writing to socket:",a),h=a):this.log(`handleData: socket sent: ${n.length} bytes`)}):this.log("handleData: socket no longer writable")),i+=n.length},__closes),h)throw h}return this.dispatchEvent(new CustomEvent("data",{detail:{incoming:e.length,outgoing:i}})),i}catch(i){throw this.log("handleData: error processing data:",i),i}}handleError(e){if(!this.active){this.log("handleError: handler not active, ignoring error");return}e.message?.includes("ECONNRESET")?this.log("handleError: client disconnected (ECONNRESET) - normal behavior"):e.message?.includes("Idle timeout")?this.log("handleError: connection idle timeout"):this.log("handleError:",e),this.active=!1,this.dispatchEvent(new CustomEvent("error",{detail:e})),this.detach(!0)}handleClose(){this.log("handleClose: socket closed"),this.active=!1,this.dispatchEvent(new CustomEvent("close")),this.detach(!1)}inspectData(e,i){if(this.inspect){console.log("-".repeat(75)),console.log(e==="incoming"?"-> incoming":"<- outgoing",i.length,"bytes");for(let t=0;t<i.length;t+=16){let r=Math.min(16,i.length-t),o="";for(let n=0;n<16;n++)if(n<r){let a=i[t+n];o+=a.toString(16).padStart(2,"0")+" "}else o+=" ";let h="";for(let n=0;n<r;n++){let a=i[t+n];h+=a>=32&&a<=126?String.fromCharCode(a):"."}console.log(`${t.toString(16).padStart(8,"0")} ${o} ${h}`)}}}};l.nextHandlerId=1;var d=l,u=class extends EventTarget{constructor(e){super();this.server=null;this.active=!1;this.handlers=new Set;this.db=e.db,e.path?this.path=e.path:(typeof e.port=="number"?this.port=e.port??e.port:this.port=5432,this.host=e.host||"127.0.0.1"),this.inspect=e.inspect??!1,this.debug=e.debug??!1,this.idleTimeout=e.idleTimeout??0,this.maxConnections=e.maxConnections??1,this.queryQueue=new c(this.db,this.debug),this.log(`constructor: created server on ${this.getServerConn()}`),this.log(`constructor: max connections: ${this.maxConnections}`),this.idleTimeout>0&&this.log(`constructor: idle timeout: ${this.idleTimeout}ms`)}log(e,...i){this.debug&&console.log(`[PGLiteSocketServer] ${e}`,...i)}async start(){if(this.log(`start: starting server on ${this.getServerConn()}`),this.server)throw new Error("Socket server already started");return await this.db.waitReady,this.active=!0,this.server=m(e=>{setImmediate(()=>this.handleConnection(e))}),this.server.maxConnections=this.maxConnections,new Promise((e,i)=>{if(!this.server)return i(new Error("Server not initialized"));if(this.server.on("error",t=>{this.log("start: server error:",t),this.dispatchEvent(new CustomEvent("error",{detail:t})),this.active||i(t)}),this.path)this.server.listen(this.path,()=>{this.log(`start: server listening on ${this.getServerConn()}`),this.dispatchEvent(new CustomEvent("listening",{detail:{path:this.path}})),e()});else{let t=this.server;t.listen(this.port,this.host,()=>{let r=t.address();if(r===null||typeof r!="object")throw Error("Expected address info");this.port=r.port,this.log(`start: server listening on ${this.getServerConn()}`),this.dispatchEvent(new CustomEvent("listening",{detail:{port:this.port,host:this.host}})),e()})}})}getServerConn(){return this.path?this.path:`${this.host}:${this.port}`}async stop(){this.log("stop: stopping server"),this.active=!1,this.log(`stop: detaching ${this.handlers.size} handlers`);for(let e of this.handlers)e.detach(!0);return this.handlers.clear(),this.server?new Promise(e=>{if(!this.server)return e();this.server.close(()=>{this.log("stop: server closed"),this.server=null,this.dispatchEvent(new CustomEvent("close")),e()})}):(this.log("stop: server not running, nothing to do"),Promise.resolve())}async handleConnection(e){let i={clientAddress:e.remoteAddress||"unknown",clientPort:e.remotePort||0};if(this.log(`handleConnection: new connection from ${i.clientAddress}:${i.clientPort}`),this.log(`handleConnection: active connections: ${this.handlers.size}, queued queries: ${this.queryQueue.getQueueLength()}`),!this.active){this.log("handleConnection: server not active, closing connection");try{e.end()}catch(r){this.log("handleConnection: error closing socket:",r)}return}if(this.handlers.size>=this.maxConnections){this.log("handleConnection: max connections reached, rejecting"),e.write(Buffer.from(`Too many connections
11+ `)),e.end();return}let t=new d({queryQueue:this.queryQueue,closeOnDetach:!0,inspect:this.inspect,debug:this.debug,idleTimeout:this.idleTimeout});this.handlers.add(t),t.addEventListener("error",r=>{let o=r.detail;o?.message?.includes("ECONNRESET")?this.log(`handler #${t.handlerId}: client disconnected (ECONNRESET)`):o?.message?.includes("Idle timeout")?this.log(`handler #${t.handlerId}: idle timeout`):this.log(`handler #${t.handlerId}: error:`,o)}),t.addEventListener("close",()=>{this.log(`handler #${t.handlerId}: closed`),this.handlers.delete(t),this.log(`handleConnection: active connections: ${this.handlers.size}`)});try{await t.attach(e),this.dispatchEvent(new CustomEvent("connection",{detail:i}))}catch(r){this.log("handleConnection: error attaching socket:",r),this.handlers.delete(t),this.dispatchEvent(new CustomEvent("error",{detail:r}));try{e.end()}catch(o){this.log("handleConnection: error closing socket:",o)}}}getStats(){return{activeConnections:this.handlers.size,queuedQueries:this.queryQueue.getQueueLength(),maxConnections:this.maxConnections}}};export{b as a,d as b,u as c};
12+ //# sourceMappingURL=chunk-NSUMFCRM.js.map
13+\ No newline at end of file