dsh-device-fleet 0.0.0-stage → 0.1.0-dev.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CONTEXT.md +123 -0
- package/LICENSE +21 -0
- package/README.md +95 -2
- package/cordis.patch.yml +24 -0
- package/docs/adr/0001-fleet-enrollment-over-account-auto-discovery.md +17 -0
- package/docs/adr/0002-shared-room-as-coordination-unit.md +17 -0
- package/docs/adr/0003-direct-call-and-room-as-two-channels.md +22 -0
- package/docs/adr/0004-direct-call-policy-owned-by-target-device.md +23 -0
- package/docs/adr/0005-upgrade-session-to-room-host.md +22 -0
- package/docs/adr/0006-addressed-messages-and-any-device-speaks.md +16 -0
- package/docs/adr/0007-user-speech-decoupled-from-device.md +21 -0
- package/docs/adr/0008-extend-global-task-list-as-room-board.md +21 -0
- package/docs/adr/0009-room-policy-configured-at-creation.md +21 -0
- package/docs/adr/0010-room-policy-v1-fields-and-custom-room-types.md +24 -0
- package/docs/adr/0011-room-board-lifecycle-and-hooks.md +27 -0
- package/docs/adr/0012-lazy-wake-and-directed-speech.md +22 -0
- package/docs/adr/0013-direct-call-as-device-agnostic-functions.md +26 -0
- package/docs/adr/0014-fleet-call-unified-entry-with-typed-shortcuts.md +22 -0
- package/docs/adr/0015-room-plus-local-teams-and-delegation.md +22 -0
- package/docs/adr/0016-delegation-upgrade-reuses-session.md +20 -0
- package/docs/adr/0017-network-phases-direct-and-relay.md +21 -0
- package/docs/adr/0018-offline-fail-fast-and-durable-room-log.md +21 -0
- package/docs/adr/0019-execution-context-configured-locally.md +24 -0
- package/docs/adr/0020-approval-inbox-with-scoped-grants.md +22 -0
- package/docs/adr/0021-room-visibility-user-full-agents-scoped.md +26 -0
- package/docs/adr/0022-tool-adoption-mechanisms.md +23 -0
- package/docs/adr/0023-reuse-session-trajectory-for-call-details.md +22 -0
- package/docs/adr/0024-strict-version-match-with-reserved-capabilities.md +22 -0
- package/docs/notes/design-decisions-summary.md +49 -0
- package/docs/notes/dsh-global-task-list-evaluation.md +54 -0
- package/docs/notes/network-v1-v2-implementation.md +71 -0
- package/docs/notes/step1-host-plugin-and-direct-call.md +57 -0
- package/package.json +45 -6
- package/src/host/direct-call.js +190 -0
- package/src/host/execution-slot.js +82 -0
- package/src/host/index.js +6 -0
- package/src/host/store.js +128 -0
- package/src/host/tool-policy.js +150 -0
- package/src/host/tools.js +215 -0
- package/src/index.js +3 -0
- package/src/net/direct.js +69 -0
- package/src/net/discovery.js +105 -0
- package/src/net/fleet-node.js +414 -0
- package/src/net/identity.js +156 -0
- package/src/net/index.js +9 -0
- package/src/net/protocol.js +73 -0
- package/src/net/relay-client.js +258 -0
- package/src/net/relay-server.js +366 -0
- package/src/net/secure-channel.js +538 -0
- package/src/plugin/index.js +276 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
// Model-facing fleet tools (ADR-0014, ADR-0022).
|
|
2
|
+
//
|
|
3
|
+
// fleet_list / fleet_tools / fleet_call are the fixed tool surface; fleet_read,
|
|
4
|
+
// fleet_fetch and fleet_run are typed shortcuts over the same fleet.call path.
|
|
5
|
+
import { DirectCallClient } from './direct-call.js'
|
|
6
|
+
|
|
7
|
+
/** Shared text output contract (same shape the harness's own tools use). */
|
|
8
|
+
export function textOutput() {
|
|
9
|
+
return {
|
|
10
|
+
schema: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
additionalProperties: false,
|
|
13
|
+
properties: { text: { type: 'string', required: true } },
|
|
14
|
+
},
|
|
15
|
+
render: (_args, value) => [{ type: 'text', text: value.text }],
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function fail(error) {
|
|
20
|
+
const code = error?.code ?? 'error'
|
|
21
|
+
return { text: `Error: [${String(code)}] ${String(error?.message ?? error)}` }
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Pure handlers; wrapped into ToolDefinitions by `createFleetToolDefinitions`. */
|
|
25
|
+
export function createFleetHandlers({ node, client = new DirectCallClient({ node }), pairing = null }) {
|
|
26
|
+
return {
|
|
27
|
+
async fleet_invite() {
|
|
28
|
+
if (!pairing?.invite) return { text: 'Error: [unsupported] this device cannot create an invite' }
|
|
29
|
+
try {
|
|
30
|
+
const code = await pairing.invite()
|
|
31
|
+
return {
|
|
32
|
+
text: [
|
|
33
|
+
'Fleet invite for another device (single use, keep it private):',
|
|
34
|
+
'',
|
|
35
|
+
code,
|
|
36
|
+
'',
|
|
37
|
+
'On the other device install this plugin, then ask its agent to join with this code (`fleet_join`). It takes effect after that DSH restarts.',
|
|
38
|
+
].join('\n'),
|
|
39
|
+
}
|
|
40
|
+
} catch (error) {
|
|
41
|
+
return fail(error)
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
async fleet_join({ code }) {
|
|
46
|
+
if (!pairing?.join) return { text: 'Error: [unsupported] this device cannot join a fleet' }
|
|
47
|
+
try {
|
|
48
|
+
await pairing.join(code)
|
|
49
|
+
return { text: 'Invite queued. Restart DSH on this device; the plugin will enroll on the next load and then be able to reach the Fleet.' }
|
|
50
|
+
} catch (error) {
|
|
51
|
+
return fail(error)
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
|
|
55
|
+
async fleet_list() {
|
|
56
|
+
const lines = []
|
|
57
|
+
lines.push(`this device: ${node.identity.deviceId} (${node.name})`)
|
|
58
|
+
const seen = new Set([node.identity.deviceId])
|
|
59
|
+
const online = new Set(node.peerIds())
|
|
60
|
+
const discovered = new Map([...node.discovered.values()].map((peer) => [peer.deviceId, peer]))
|
|
61
|
+
for (const entry of node.rosterEntries()) {
|
|
62
|
+
if (seen.has(entry.deviceId)) continue
|
|
63
|
+
seen.add(entry.deviceId)
|
|
64
|
+
lines.push(`- ${entry.deviceId} (${entry.name ?? 'device'}) ${online.has(entry.deviceId) ? 'online' : 'offline'}${online.has(entry.deviceId) ? '' : ' — direct calls fail while offline'}`)
|
|
65
|
+
}
|
|
66
|
+
for (const [deviceId, peer] of discovered) {
|
|
67
|
+
if (seen.has(deviceId)) continue
|
|
68
|
+
seen.add(deviceId)
|
|
69
|
+
lines.push(`- ${deviceId} (${peer.name ?? 'device'}) discovered${online.has(deviceId) ? ', online' : ''}`)
|
|
70
|
+
}
|
|
71
|
+
if (lines.length === 1) lines.push('(no other devices known yet; pair/enroll one with an invite first)')
|
|
72
|
+
return { text: lines.join('\n') }
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
async fleet_tools({ device }) {
|
|
76
|
+
try {
|
|
77
|
+
const result = await client.listTools(device)
|
|
78
|
+
const tools = Array.isArray(result?.tools) ? result.tools : []
|
|
79
|
+
if (tools.length === 0) return { text: `(no tools visible on ${device})` }
|
|
80
|
+
const lines = tools.map((tool) => `- ${tool.name}${tool.description ? `: ${String(tool.description).slice(0, 160)}` : ''}`)
|
|
81
|
+
return { text: lines.join('\n') }
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return fail(error)
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
|
|
87
|
+
async fleet_call({ device, tool, parameters }) {
|
|
88
|
+
try {
|
|
89
|
+
const result = await client.call(device, { capability: 'tool', tool, args: parameters ?? {} })
|
|
90
|
+
return { text: client.render(result) }
|
|
91
|
+
} catch (error) {
|
|
92
|
+
return fail(error)
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
|
|
96
|
+
async fleet_read({ device, path, offset, limit }) {
|
|
97
|
+
try {
|
|
98
|
+
const args = { file_path: path }
|
|
99
|
+
if (offset !== undefined) args.offset = offset
|
|
100
|
+
if (limit !== undefined) args.limit = limit
|
|
101
|
+
const result = await client.call(device, { capability: 'read', tool: 'read', args })
|
|
102
|
+
return { text: client.render(result) }
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return fail(error)
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
async fleet_fetch({ device, path }) {
|
|
109
|
+
try {
|
|
110
|
+
const result = await client.call(device, { capability: 'fetch', args: { path } })
|
|
111
|
+
return { text: client.render(result) }
|
|
112
|
+
} catch (error) {
|
|
113
|
+
return fail(error)
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
|
|
117
|
+
async fleet_run({ device, command }) {
|
|
118
|
+
try {
|
|
119
|
+
const result = await client.call(device, { capability: 'run', args: { command } })
|
|
120
|
+
return { text: client.render(result) }
|
|
121
|
+
} catch (error) {
|
|
122
|
+
return fail(error)
|
|
123
|
+
}
|
|
124
|
+
},
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Build the model-facing ToolDefinitions once `defineTool` is available. */
|
|
129
|
+
export function createFleetToolDefinitions({ defineTool, handlers }) {
|
|
130
|
+
const output = textOutput()
|
|
131
|
+
const commonDevice = { type: 'string', required: true, description: 'Target device id from `fleet_list`.' }
|
|
132
|
+
return [
|
|
133
|
+
defineTool({
|
|
134
|
+
name: 'fleet_invite',
|
|
135
|
+
description:
|
|
136
|
+
'Create a Fleet invite code for another device. Only do this when the user asks to pair a new device. Treat the returned code as a secret; it carries the fleet key.',
|
|
137
|
+
parameters: {},
|
|
138
|
+
output,
|
|
139
|
+
execute: () => handlers.fleet_invite(),
|
|
140
|
+
}),
|
|
141
|
+
defineTool({
|
|
142
|
+
name: 'fleet_join',
|
|
143
|
+
description:
|
|
144
|
+
'Join an existing Fleet with an invite code from `fleet_invite` on another device. The enrollment is queued and takes effect after DSH restarts on this device.',
|
|
145
|
+
parameters: {
|
|
146
|
+
code: { type: 'string', required: true, description: 'Invite code produced by fleet_invite on another device.' },
|
|
147
|
+
},
|
|
148
|
+
output,
|
|
149
|
+
execute: (args) => handlers.fleet_join(args),
|
|
150
|
+
}),
|
|
151
|
+
defineTool({
|
|
152
|
+
name: 'fleet_list',
|
|
153
|
+
description:
|
|
154
|
+
'List the devices in this Fleet with their online status. Call it before any `fleet_*` call to learn which device id to use. Offline devices are listed too, but direct calls to them fail fast; use a Room/delegation instead of waiting.',
|
|
155
|
+
parameters: {},
|
|
156
|
+
output,
|
|
157
|
+
execute: () => handlers.fleet_list(),
|
|
158
|
+
}),
|
|
159
|
+
defineTool({
|
|
160
|
+
name: 'fleet_tools',
|
|
161
|
+
description:
|
|
162
|
+
'List the tools another device exposes to direct calls (name + description). Use it only when you need an untyped tool; the typed shortcuts `fleet_read` / `fleet_fetch` / `fleet_run` cover the common cases.',
|
|
163
|
+
parameters: { device: commonDevice },
|
|
164
|
+
output,
|
|
165
|
+
execute: (args) => handlers.fleet_tools(args),
|
|
166
|
+
}),
|
|
167
|
+
defineTool({
|
|
168
|
+
name: 'fleet_call',
|
|
169
|
+
description:
|
|
170
|
+
'Call one tool on another device directly. Direct calls are stateless: no Room, no shared conversation, no remote history. Use `fleet_tools` first to discover names/arguments, or prefer the typed shortcuts. The target device decides whether to allow, ask its user, or reject.',
|
|
171
|
+
parameters: {
|
|
172
|
+
device: commonDevice,
|
|
173
|
+
tool: { type: 'string', required: true, description: 'Tool name on the target device.' },
|
|
174
|
+
parameters: { type: 'object', additionalProperties: true, description: 'Arguments object for that tool.' },
|
|
175
|
+
},
|
|
176
|
+
output,
|
|
177
|
+
execute: (args) => handlers.fleet_call(args),
|
|
178
|
+
}),
|
|
179
|
+
defineTool({
|
|
180
|
+
name: 'fleet_read',
|
|
181
|
+
description:
|
|
182
|
+
'Read a file on another device (shortcut over `fleet_call` with the target `read` tool). Paths must be inside the roots that device allows. Use it to inspect logs/docs on another machine without opening a Room.',
|
|
183
|
+
parameters: {
|
|
184
|
+
device: commonDevice,
|
|
185
|
+
path: { type: 'string', required: true, description: 'Absolute path on the target device.' },
|
|
186
|
+
offset: { type: 'number', description: 'Line offset, when supported by the target read tool.' },
|
|
187
|
+
limit: { type: 'number', description: 'Maximum lines to return.' },
|
|
188
|
+
},
|
|
189
|
+
output,
|
|
190
|
+
execute: (args) => handlers.fleet_read(args),
|
|
191
|
+
}),
|
|
192
|
+
defineTool({
|
|
193
|
+
name: 'fleet_fetch',
|
|
194
|
+
description:
|
|
195
|
+
'Fetch a file from another device and return its text content. Use it for "look at B\'s file and bring it over"; for large binaries prefer `fleet_run` with a transfer command.',
|
|
196
|
+
parameters: {
|
|
197
|
+
device: commonDevice,
|
|
198
|
+
path: { type: 'string', required: true, description: 'Absolute path on the target device.' },
|
|
199
|
+
},
|
|
200
|
+
output,
|
|
201
|
+
execute: (args) => handlers.fleet_fetch(args),
|
|
202
|
+
}),
|
|
203
|
+
defineTool({
|
|
204
|
+
name: 'fleet_run',
|
|
205
|
+
description:
|
|
206
|
+
'Run a shell command on another device (shortcut over `fleet_call` with the target shell tool). The target device\'s Tool Policy decides whether the command is auto-allowed, needs its local approval, or is rejected; an offline or timed-out approval fails. Use it for builds/tests and other work that belongs on that machine.',
|
|
207
|
+
parameters: {
|
|
208
|
+
device: commonDevice,
|
|
209
|
+
command: { type: 'string', required: true, description: 'Shell command to run on the target device.' },
|
|
210
|
+
},
|
|
211
|
+
output,
|
|
212
|
+
execute: (args) => handlers.fleet_run(args),
|
|
213
|
+
}),
|
|
214
|
+
]
|
|
215
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// Plain TCP direct transport: a listening server and an outbound dialer.
|
|
2
|
+
// The secure channel runs on top; this module only owns sockets.
|
|
3
|
+
import net from 'node:net'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Start a direct listener.
|
|
7
|
+
* @param {{host?: string, port?: number}} [options]
|
|
8
|
+
* @returns {{server: net.Server, ready: Promise<{host: string, port: number}>, close: () => Promise<void>}}
|
|
9
|
+
*/
|
|
10
|
+
export function createDirectServer({ host = '0.0.0.0', port = 0 } = {}) {
|
|
11
|
+
const server = net.createServer({ allowHalfOpen: false })
|
|
12
|
+
const ready = new Promise((resolve, reject) => {
|
|
13
|
+
const onError = (error) => {
|
|
14
|
+
server.off('listening', onListening)
|
|
15
|
+
reject(error)
|
|
16
|
+
}
|
|
17
|
+
const onListening = () => {
|
|
18
|
+
server.off('error', onError)
|
|
19
|
+
const address = server.address()
|
|
20
|
+
resolve({ host: typeof address === 'object' && address ? address.address : host, port: address?.port ?? port })
|
|
21
|
+
}
|
|
22
|
+
server.once('error', onError)
|
|
23
|
+
server.once('listening', onListening)
|
|
24
|
+
server.listen(port, host)
|
|
25
|
+
})
|
|
26
|
+
return {
|
|
27
|
+
server,
|
|
28
|
+
ready,
|
|
29
|
+
close: () =>
|
|
30
|
+
new Promise((resolve) => {
|
|
31
|
+
if (!server.listening) return resolve()
|
|
32
|
+
server.close(() => resolve())
|
|
33
|
+
}),
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Dial a direct endpoint.
|
|
39
|
+
* @param {{host: string, port: number, timeoutMs?: number}} options
|
|
40
|
+
* @returns {Promise<import('node:net').Socket>}
|
|
41
|
+
*/
|
|
42
|
+
export function connectDirect({ host, port, timeoutMs = 5_000 }) {
|
|
43
|
+
return new Promise((resolve, reject) => {
|
|
44
|
+
const socket = net.connect({ host, port })
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
cleanup()
|
|
47
|
+
socket.destroy()
|
|
48
|
+
const error = new Error(`direct connect to ${host}:${String(port)} timed out`)
|
|
49
|
+
error.code = 'connect-timeout'
|
|
50
|
+
reject(error)
|
|
51
|
+
}, timeoutMs)
|
|
52
|
+
timer.unref?.()
|
|
53
|
+
const cleanup = () => {
|
|
54
|
+
clearTimeout(timer)
|
|
55
|
+
socket.off('connect', onConnect)
|
|
56
|
+
socket.off('error', onError)
|
|
57
|
+
}
|
|
58
|
+
const onConnect = () => {
|
|
59
|
+
cleanup()
|
|
60
|
+
resolve(socket)
|
|
61
|
+
}
|
|
62
|
+
const onError = (error) => {
|
|
63
|
+
cleanup()
|
|
64
|
+
reject(error)
|
|
65
|
+
}
|
|
66
|
+
socket.once('connect', onConnect)
|
|
67
|
+
socket.once('error', onError)
|
|
68
|
+
})
|
|
69
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// LAN discovery over UDP multicast. Announcements carry only a fleet hash,
|
|
2
|
+
// device id, display name and the direct listening port; the real trust
|
|
3
|
+
// decision happens in the secure-channel handshake.
|
|
4
|
+
import dgram from 'node:dgram'
|
|
5
|
+
import { EventEmitter } from 'node:events'
|
|
6
|
+
import { fleetHash } from './identity.js'
|
|
7
|
+
|
|
8
|
+
export const DEFAULT_DISCOVERY_GROUP = '239.255.42.99'
|
|
9
|
+
export const DEFAULT_DISCOVERY_PORT = 47833
|
|
10
|
+
|
|
11
|
+
export class LanDiscovery extends EventEmitter {
|
|
12
|
+
constructor({
|
|
13
|
+
fleetId,
|
|
14
|
+
deviceId,
|
|
15
|
+
name = 'device',
|
|
16
|
+
directPort,
|
|
17
|
+
group = DEFAULT_DISCOVERY_GROUP,
|
|
18
|
+
port = DEFAULT_DISCOVERY_PORT,
|
|
19
|
+
intervalMs = 3_000,
|
|
20
|
+
}) {
|
|
21
|
+
super()
|
|
22
|
+
this.fleetId = fleetId
|
|
23
|
+
this.deviceId = deviceId
|
|
24
|
+
this.name = name
|
|
25
|
+
this.directPort = directPort
|
|
26
|
+
this.group = group
|
|
27
|
+
this.port = port
|
|
28
|
+
this.intervalMs = intervalMs
|
|
29
|
+
this.socket = null
|
|
30
|
+
this.timer = null
|
|
31
|
+
this.closed = false
|
|
32
|
+
this.peers = new Map()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
start() {
|
|
36
|
+
if (this.socket) return
|
|
37
|
+
this.socket = dgram.createSocket({ type: 'udp4', reuseAddr: true })
|
|
38
|
+
this.socket.on('message', (buffer, rinfo) => this.onMessage(buffer, rinfo))
|
|
39
|
+
this.socket.on('error', (error) => this.emit('error', error))
|
|
40
|
+
this.socket.bind(this.port, () => {
|
|
41
|
+
try {
|
|
42
|
+
this.socket.addMembership(this.group)
|
|
43
|
+
this.socket.setMulticastTTL(1)
|
|
44
|
+
this.socket.setMulticastLoopback(true)
|
|
45
|
+
} catch (error) {
|
|
46
|
+
this.emit('error', error)
|
|
47
|
+
}
|
|
48
|
+
this.announce()
|
|
49
|
+
this.timer = setInterval(() => this.announce(), this.intervalMs)
|
|
50
|
+
this.timer.unref?.()
|
|
51
|
+
this.emit('listening', { group: this.group, port: this.port })
|
|
52
|
+
})
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
announce() {
|
|
56
|
+
if (!this.socket || this.closed) return
|
|
57
|
+
const payload = Buffer.from(
|
|
58
|
+
JSON.stringify({
|
|
59
|
+
v: 1,
|
|
60
|
+
fleetHash: fleetHash(this.fleetId),
|
|
61
|
+
deviceId: this.deviceId,
|
|
62
|
+
name: this.name,
|
|
63
|
+
port: this.directPort,
|
|
64
|
+
ts: Date.now(),
|
|
65
|
+
}),
|
|
66
|
+
)
|
|
67
|
+
this.socket.send(payload, 0, payload.length, this.port, this.group, () => {})
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
onMessage(buffer, rinfo) {
|
|
71
|
+
if (this.closed) return
|
|
72
|
+
let message
|
|
73
|
+
try {
|
|
74
|
+
message = JSON.parse(buffer.toString('utf8'))
|
|
75
|
+
} catch {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (message?.v !== 1) return
|
|
79
|
+
if (message.fleetHash !== fleetHash(this.fleetId)) return
|
|
80
|
+
if (typeof message.deviceId !== 'string' || message.deviceId === this.deviceId) return
|
|
81
|
+
if (typeof message.port !== 'number' || message.port <= 0) return
|
|
82
|
+
const peer = {
|
|
83
|
+
deviceId: message.deviceId,
|
|
84
|
+
name: typeof message.name === 'string' ? message.name : 'device',
|
|
85
|
+
host: rinfo.address,
|
|
86
|
+
port: message.port,
|
|
87
|
+
lastSeen: Date.now(),
|
|
88
|
+
}
|
|
89
|
+
this.peers.set(peer.deviceId, peer)
|
|
90
|
+
this.emit('peer', peer)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
close() {
|
|
94
|
+
this.closed = true
|
|
95
|
+
clearInterval(this.timer)
|
|
96
|
+
if (this.socket) {
|
|
97
|
+
try {
|
|
98
|
+
this.socket.close()
|
|
99
|
+
} catch {
|
|
100
|
+
/* ignore */
|
|
101
|
+
}
|
|
102
|
+
this.socket = null
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|