fix(watch): keep connect() error listener attached until connection completes (#2923)
The listener was removed synchronously in a finally block, so an async
connection failure (e.g. a missing or stale socket file) emitted an
'error' event with no listeners attached, crashing the process with an
uncaught exception instead of rejecting the connect() promise.
Ref #2894
### Changes are visible to end-users: no
### Test plan
- Covered by existing test cases
- New test cases added
diff --git a/js/private/devserver/js_run_devserver.mjs b/js/private/devserver/js_run_devserver.mjs
index 140086c..4072a54 100644
--- a/js/private/devserver/js_run_devserver.mjs
+++ b/js/private/devserver/js_run_devserver.mjs
@@ -54,13 +54,14 @@
// Initial connection + success vs failure
this.connection.once('error', reject);
try {
- this.connection.connect(this.socketFile, resolve);
+ this.connection.connect(this.socketFile, () => {
+ this.connection.off('error', reject);
+ resolve();
+ });
}
catch (err) {
- reject(err);
- }
- finally {
this.connection.off('error', reject);
+ reject(err);
}
});
const { versions } = await this._receive(MessageType.NEGOTIATE);
diff --git a/js/private/test/watch/BUILD.bazel b/js/private/test/watch/BUILD.bazel
index 205eb0a..55a6663 100644
--- a/js/private/test/watch/BUILD.bazel
+++ b/js/private/test/watch/BUILD.bazel
@@ -1,6 +1,12 @@
load("//js:defs.bzl", "js_test")
js_test(
+ name = "connect_error_test",
+ data = ["//js/private/watch"],
+ entry_point = "connect_error.test.mjs",
+)
+
+js_test(
name = "exit_shutdown_test",
data = ["//js/private/watch"],
entry_point = "exit_shutdown.test.mjs",
diff --git a/js/private/test/watch/connect_error.test.mjs b/js/private/test/watch/connect_error.test.mjs
new file mode 100644
index 0000000..a6bf433
--- /dev/null
+++ b/js/private/test/watch/connect_error.test.mjs
@@ -0,0 +1,19 @@
+// Test that AspectWatchProtocol.connect() failures reject catchably instead of
+// emitting an unhandled socket 'error' event that crashes the process.
+import * as os from 'node:os'
+import * as path from 'node:path'
+import * as assert from 'node:assert'
+import { AspectWatchProtocol } from '../../watch/aspect_watch_protocol.mjs'
+
+const missingSocket = path.join(
+ os.tmpdir(),
+ `watch-proto-test-${process.pid}-missing.sock`
+)
+
+const w = new AspectWatchProtocol(missingSocket)
+await assert.rejects(() => w.connect(), /ENOENT|ECONNREFUSED/)
+
+// Give any stray async 'error' event a chance to crash us if mishandled.
+await new Promise((r) => setTimeout(r, 50))
+
+console.log('PASS: connect() to missing socket rejects catchably')
diff --git a/js/private/watch/aspect_watch_protocol.mjs b/js/private/watch/aspect_watch_protocol.mjs
index 78cad7a..de3ca4c 100644
--- a/js/private/watch/aspect_watch_protocol.mjs
+++ b/js/private/watch/aspect_watch_protocol.mjs
@@ -46,13 +46,14 @@
// Initial connection + success vs failure
this.connection.once('error', reject);
try {
- this.connection.connect(this.socketFile, resolve);
+ this.connection.connect(this.socketFile, () => {
+ this.connection.off('error', reject);
+ resolve();
+ });
}
catch (err) {
- reject(err);
- }
- finally {
this.connection.off('error', reject);
+ reject(err);
}
});
const { versions } = await this._receive(MessageType.NEGOTIATE);
diff --git a/js/private/watch/src/aspect_watch_protocol.mts b/js/private/watch/src/aspect_watch_protocol.mts
index ad7dfe3..a8468f0 100644
--- a/js/private/watch/src/aspect_watch_protocol.mts
+++ b/js/private/watch/src/aspect_watch_protocol.mts
@@ -134,11 +134,13 @@
// Initial connection + success vs failure
this.connection.once('error', reject)
try {
- this.connection.connect(this.socketFile, resolve)
+ this.connection.connect(this.socketFile, () => {
+ this.connection.off('error', reject)
+ resolve()
+ })
} catch (err) {
- reject(err)
- } finally {
this.connection.off('error', reject)
+ reject(err)
}
})