unitup 0.2.0 → 0.3.0

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.
@@ -0,0 +1,133 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getUnitupDir, sanitizeServiceName } from '../utils.js';
4
+
5
+ export class DeploymentLockError extends Error {
6
+ constructor(message, service, lockedPid) {
7
+ super(message);
8
+ this.name = 'DeploymentLockError';
9
+ this.service = service;
10
+ this.lockedPid = lockedPid;
11
+ }
12
+ }
13
+
14
+ /**
15
+ * Manages per-service deployment locks to prevent concurrent deployments.
16
+ */
17
+ export class DeploymentLock {
18
+ /**
19
+ * @param {object} [options]
20
+ * @param {string} [options.lockDir]
21
+ * @param {import('./process-manager.js').ProcessManager} [options.processManager]
22
+ */
23
+ constructor(options = {}) {
24
+ this.lockDir = options.lockDir || path.join(getUnitupDir(), 'locks');
25
+ this.processManager = options.processManager;
26
+ this._heldLocks = new Set();
27
+ }
28
+
29
+ getLockFilePath(serviceName) {
30
+ const safeName = sanitizeServiceName(serviceName);
31
+ return path.join(this.lockDir, `${safeName}.deploy.lock`);
32
+ }
33
+
34
+ /**
35
+ * Acquires the deployment lock for a service.
36
+ * Throws DeploymentLockError if already locked by an active process.
37
+ *
38
+ * @param {string} serviceName
39
+ * @returns {Promise<boolean>}
40
+ */
41
+ async acquire(serviceName) {
42
+ const safeName = sanitizeServiceName(serviceName);
43
+
44
+ if (this._heldLocks.has(safeName)) {
45
+ throw new DeploymentLockError(
46
+ `Deployment already in progress for service "${safeName}" (PID ${process.pid}).`,
47
+ safeName,
48
+ process.pid
49
+ );
50
+ }
51
+
52
+ const lockPath = this.getLockFilePath(safeName);
53
+
54
+ if (!fs.existsSync(this.lockDir)) {
55
+ fs.mkdirSync(this.lockDir, { recursive: true });
56
+ }
57
+
58
+ if (fs.existsSync(lockPath)) {
59
+ try {
60
+ const raw = fs.readFileSync(lockPath, 'utf8');
61
+ const info = JSON.parse(raw);
62
+ const lockedPid = Number(info.pid);
63
+
64
+ // Check if the holding process is still alive
65
+ let isAlive = false;
66
+ if (this.processManager) {
67
+ isAlive = this.processManager.isAlive(lockedPid);
68
+ } else if (lockedPid > 0) {
69
+ try {
70
+ process.kill(lockedPid, 0);
71
+ isAlive = true;
72
+ } catch (err) {
73
+ isAlive = err.code === 'EPERM';
74
+ }
75
+ }
76
+
77
+ if (isAlive) {
78
+ throw new DeploymentLockError(
79
+ `Deployment already in progress for service "${safeName}" (PID ${lockedPid}).`,
80
+ safeName,
81
+ lockedPid
82
+ );
83
+ }
84
+
85
+ // Process is dead: break stale lock
86
+ try {
87
+ fs.unlinkSync(lockPath);
88
+ } catch {
89
+ // ignore
90
+ }
91
+ } catch (err) {
92
+ if (err instanceof DeploymentLockError) {
93
+ throw err;
94
+ }
95
+ // Stale or corrupted lock file, overwrite
96
+ }
97
+ }
98
+
99
+ const payload = {
100
+ service: safeName,
101
+ pid: process.pid,
102
+ timestamp: new Date().toISOString()
103
+ };
104
+
105
+ fs.writeFileSync(lockPath, JSON.stringify(payload, null, 2), { flag: 'wx' });
106
+ this._heldLocks.add(safeName);
107
+ return true;
108
+ }
109
+
110
+ /**
111
+ * Releases the deployment lock for a service.
112
+ *
113
+ * @param {string} serviceName
114
+ * @returns {Promise<boolean>}
115
+ */
116
+ async release(serviceName) {
117
+ const safeName = sanitizeServiceName(serviceName);
118
+ const lockPath = this.getLockFilePath(safeName);
119
+
120
+ this._heldLocks.delete(safeName);
121
+
122
+ if (fs.existsSync(lockPath)) {
123
+ try {
124
+ fs.unlinkSync(lockPath);
125
+ return true;
126
+ } catch {
127
+ return false;
128
+ }
129
+ }
130
+
131
+ return true;
132
+ }
133
+ }
@@ -0,0 +1,313 @@
1
+ import { spawn, execFile } from 'node:child_process';
2
+ import fs from 'node:fs';
3
+ import path from 'node:path';
4
+
5
+ /**
6
+ * ProcessManager is responsible ONLY for low-level process lifecycle management.
7
+ * It contains NO deployment sequencing, routing, or drain logic.
8
+ */
9
+ export class ProcessManager {
10
+ constructor(options = {}) {
11
+ this.processes = new Map(); // pid -> processEntry
12
+ this.recentExits = new Map(); // pid -> exit info { pid, exitCode, exitSignal, recentStderr }
13
+ this.defaultShutdownTimeout = options.defaultShutdownTimeout || 5000;
14
+ }
15
+
16
+ /**
17
+ * Spawns a new process.
18
+ *
19
+ * @param {string} command - Executable path or command name
20
+ * @param {string[]} [args] - Command line arguments
21
+ * @param {object} [options] - Spawn options
22
+ * @param {string} [options.cwd]
23
+ * @param {object} [options.env]
24
+ * @param {string} [options.stdoutPath]
25
+ * @param {string} [options.stderrPath]
26
+ * @returns {object} Process record { pid, child, exitPromise }
27
+ */
28
+ start(command, args = [], options = {}) {
29
+ const cwd = options.cwd || process.cwd();
30
+ const env = { ...process.env, ...(options.env || {}) };
31
+
32
+ let cmd = command;
33
+ let cmdArgs = Array.isArray(args) ? [...args] : [];
34
+
35
+ // On Windows, if executing a batch script, wrap with cmd.exe
36
+ if (process.platform === 'win32' && /\.(bat|cmd)$/i.test(cmd)) {
37
+ cmdArgs = ['/d', '/s', '/c', `"${cmd}"`, ...cmdArgs];
38
+ cmd = process.env.COMSPEC || 'cmd.exe';
39
+ }
40
+
41
+ const logStreams = [];
42
+ let stdoutStream = null;
43
+ let stderrStream = null;
44
+
45
+ if (options.stdoutPath) {
46
+ const stdoutDir = path.dirname(options.stdoutPath);
47
+ if (!fs.existsSync(stdoutDir)) {
48
+ fs.mkdirSync(stdoutDir, { recursive: true });
49
+ }
50
+ stdoutStream = fs.createWriteStream(options.stdoutPath, { flags: 'a' });
51
+ logStreams.push(stdoutStream);
52
+ }
53
+
54
+ if (options.stderrPath) {
55
+ const stderrDir = path.dirname(options.stderrPath);
56
+ if (!fs.existsSync(stderrDir)) {
57
+ fs.mkdirSync(stderrDir, { recursive: true });
58
+ }
59
+ stderrStream = fs.createWriteStream(options.stderrPath, { flags: 'a' });
60
+ logStreams.push(stderrStream);
61
+ }
62
+
63
+ const child = spawn(cmd, cmdArgs, {
64
+ cwd,
65
+ env,
66
+ stdio: ['ignore', stdoutStream ? 'pipe' : 'inherit', 'pipe'],
67
+ shell: false,
68
+ windowsHide: true
69
+ });
70
+
71
+ if (stdoutStream && child.stdout) {
72
+ child.stdout.pipe(stdoutStream, { end: false });
73
+ }
74
+
75
+ let recentStderr = '';
76
+ if (child.stderr) {
77
+ child.stderr.on('data', (chunk) => {
78
+ recentStderr += chunk.toString();
79
+ if (recentStderr.length > 8192) {
80
+ recentStderr = recentStderr.slice(-8192);
81
+ }
82
+ });
83
+ if (stderrStream) {
84
+ child.stderr.pipe(stderrStream, { end: false });
85
+ }
86
+ }
87
+
88
+ const pid = child.pid;
89
+ let exitResolver;
90
+ const exitPromise = new Promise((resolve) => {
91
+ exitResolver = resolve;
92
+ });
93
+
94
+ const entry = {
95
+ pid,
96
+ command: cmd,
97
+ args: cmdArgs,
98
+ child,
99
+ exitPromise,
100
+ exitCode: null,
101
+ exitSignal: null,
102
+ exited: false,
103
+ logStreams,
104
+ get recentStderr() {
105
+ return recentStderr;
106
+ }
107
+ };
108
+
109
+ child.on('exit', (code, signal) => {
110
+ entry.exitCode = code;
111
+ entry.exitSignal = signal;
112
+ entry.exited = true;
113
+
114
+ // Close log streams
115
+ for (const stream of entry.logStreams) {
116
+ try {
117
+ if (stream && !stream.destroyed) stream.end();
118
+ } catch {
119
+ // ignore
120
+ }
121
+ }
122
+
123
+ this.processes.delete(pid);
124
+ this.recentExits.set(pid, {
125
+ pid,
126
+ command: cmd,
127
+ exitCode: code,
128
+ exitSignal: signal,
129
+ recentStderr
130
+ });
131
+ if (this.recentExits.size > 50) {
132
+ const first = this.recentExits.keys().next().value;
133
+ this.recentExits.delete(first);
134
+ }
135
+
136
+ exitResolver({ code, signal });
137
+ });
138
+
139
+ child.on('error', (err) => {
140
+ entry.error = err;
141
+ if (!entry.exited) {
142
+ entry.exited = true;
143
+ this.processes.delete(pid);
144
+ this.recentExits.set(pid, {
145
+ pid,
146
+ command: cmd,
147
+ exitCode: 1,
148
+ exitSignal: null,
149
+ recentStderr: err.message || recentStderr
150
+ });
151
+ exitResolver({ code: 1, signal: null, error: err });
152
+ }
153
+ });
154
+
155
+ if (pid) {
156
+ this.processes.set(pid, entry);
157
+ }
158
+
159
+ return {
160
+ pid,
161
+ child,
162
+ exitPromise
163
+ };
164
+ }
165
+
166
+ /**
167
+ * Retrieves process info for an active or recently exited process.
168
+ *
169
+ * @param {number|object} processOrPid
170
+ * @returns {object|null}
171
+ */
172
+ getProcess(processOrPid) {
173
+ const pid = typeof processOrPid === 'number' ? processOrPid : processOrPid?.pid;
174
+ return this.processes.get(pid) || this.recentExits.get(pid) || null;
175
+ }
176
+
177
+ /**
178
+ * Checks if a process is alive.
179
+ *
180
+ * @param {number|object} processOrPid
181
+ * @returns {boolean}
182
+ */
183
+ isAlive(processOrPid) {
184
+ const pid = typeof processOrPid === 'number' ? processOrPid : processOrPid?.pid;
185
+ if (!pid || typeof pid !== 'number' || pid <= 0) return false;
186
+
187
+ // Check internal tracked state
188
+ const entry = this.processes.get(pid);
189
+ if (entry && entry.exited) return false;
190
+
191
+ try {
192
+ // kill(0) tests whether the process exists without sending a signal
193
+ process.kill(pid, 0);
194
+ return true;
195
+ } catch (err) {
196
+ // ESRCH means process doesn't exist. EPERM means process exists but we lack permission to signal.
197
+ return err.code === 'EPERM';
198
+ }
199
+ }
200
+
201
+ /**
202
+ * Sends a signal to a process.
203
+ *
204
+ * @param {number|object} processOrPid
205
+ * @param {string|number} [signal='SIGTERM']
206
+ * @returns {boolean}
207
+ */
208
+ signal(processOrPid, signal = 'SIGTERM') {
209
+ const pid = typeof processOrPid === 'number' ? processOrPid : processOrPid?.pid;
210
+ if (!pid || !this.isAlive(pid)) return false;
211
+
212
+ try {
213
+ process.kill(pid, signal);
214
+ return true;
215
+ } catch {
216
+ return false;
217
+ }
218
+ }
219
+
220
+ /**
221
+ * Waits for a process to exit up to an optional timeout.
222
+ *
223
+ * @param {number|object} processOrPid
224
+ * @param {number} [timeout]
225
+ * @returns {Promise<{ exited: boolean, code?: number, signal?: string }>}
226
+ */
227
+ async waitForExit(processOrPid, timeout) {
228
+ const pid = typeof processOrPid === 'number' ? processOrPid : processOrPid?.pid;
229
+ if (!pid) return { exited: true };
230
+
231
+ const entry = this.processes.get(pid);
232
+ if (!this.isAlive(pid)) {
233
+ return { exited: true, code: entry?.exitCode ?? null, signal: entry?.exitSignal ?? null };
234
+ }
235
+
236
+ if (entry && entry.exitPromise) {
237
+ if (!timeout || timeout <= 0) {
238
+ const res = await entry.exitPromise;
239
+ return { exited: true, code: res.code, signal: res.signal };
240
+ }
241
+
242
+ let timer;
243
+ const timeoutPromise = new Promise((resolve) => {
244
+ timer = setTimeout(() => resolve({ exited: false }), timeout);
245
+ });
246
+
247
+ const res = await Promise.race([
248
+ entry.exitPromise.then((r) => ({ exited: true, code: r.code, signal: r.signal })),
249
+ timeoutPromise
250
+ ]);
251
+ clearTimeout(timer);
252
+ return res;
253
+ }
254
+
255
+ // Process is external or untracked PID: poll liveness
256
+ const pollInterval = 50;
257
+ const start = Date.now();
258
+ while (this.isAlive(pid)) {
259
+ if (timeout && Date.now() - start >= timeout) {
260
+ return { exited: false };
261
+ }
262
+ await new Promise((resolve) => setTimeout(resolve, pollInterval));
263
+ }
264
+ return { exited: true };
265
+ }
266
+
267
+ /**
268
+ * Stops a process gracefully, then force kills if it does not exit within timeout.
269
+ *
270
+ * @param {number|object} processOrPid
271
+ * @param {object} [options]
272
+ * @param {number} [options.timeout]
273
+ * @param {string} [options.signal='SIGTERM']
274
+ * @returns {Promise<boolean>}
275
+ */
276
+ async stop(processOrPid, options = {}) {
277
+ const pid = typeof processOrPid === 'number' ? processOrPid : processOrPid?.pid;
278
+ if (!pid || !this.isAlive(pid)) return true;
279
+
280
+ const timeout = options.timeout !== undefined ? options.timeout : this.defaultShutdownTimeout;
281
+ const termSignal = options.signal || 'SIGTERM';
282
+
283
+ // 1. Send graceful termination signal
284
+ this.signal(pid, termSignal);
285
+
286
+ // 2. Wait up to timeout
287
+ const exitResult = await this.waitForExit(pid, timeout);
288
+ if (exitResult.exited) {
289
+ return true;
290
+ }
291
+
292
+ // 3. Force kill if still alive
293
+ if (this.isAlive(pid)) {
294
+ if (process.platform === 'win32') {
295
+ try {
296
+ await new Promise((resolve) => {
297
+ execFile('taskkill', ['/pid', String(pid), '/T', '/F'], () => resolve());
298
+ });
299
+ } catch {
300
+ // ignore
301
+ }
302
+ }
303
+
304
+ this.signal(pid, 'SIGKILL');
305
+
306
+ // Final short grace period for SIGKILL to take effect
307
+ const finalWait = await this.waitForExit(pid, 2000);
308
+ return finalWait.exited;
309
+ }
310
+
311
+ return true;
312
+ }
313
+ }
@@ -0,0 +1,243 @@
1
+ import http from 'node:http';
2
+ import net from 'node:net';
3
+
4
+ /**
5
+ * Custom error thrown when a generation fails readiness within the timeout.
6
+ */
7
+ export class ReadinessTimeoutError extends Error {
8
+ constructor(message, generation) {
9
+ super(message);
10
+ this.name = 'ReadinessTimeoutError';
11
+ this.generation = generation;
12
+ }
13
+ }
14
+
15
+ /**
16
+ * Custom error thrown when a generation's process exits unexpectedly during startup.
17
+ */
18
+ export class ProcessStartupError extends Error {
19
+ constructor(message, generation) {
20
+ super(message);
21
+ this.name = 'ProcessStartupError';
22
+ this.generation = generation;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Custom error thrown when an application listens on a hardcoded port instead of process.env.PORT.
28
+ */
29
+ export class PortMismatchError extends Error {
30
+ constructor(message, generation, detectedPort) {
31
+ super(message);
32
+ this.name = 'PortMismatchError';
33
+ this.generation = generation;
34
+ this.detectedPort = detectedPort;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * ReadinessChecker provides reusable TCP and HTTP readiness verification.
40
+ */
41
+ export class ReadinessChecker {
42
+ /**
43
+ * @param {object} options
44
+ * @param {import('./process-manager.js').ProcessManager} options.processManager
45
+ */
46
+ constructor(options = {}) {
47
+ this.processManager = options.processManager;
48
+ }
49
+
50
+ /**
51
+ * Checks if an internal TCP port is accepting connections.
52
+ *
53
+ * @param {number} port
54
+ * @param {string} [host='127.0.0.1']
55
+ * @param {number} [timeout=1000]
56
+ * @returns {Promise<boolean>}
57
+ */
58
+ checkTcp(port, host = '127.0.0.1', timeout = 1000) {
59
+ return new Promise((resolve) => {
60
+ const socket = net.connect({ port, host, timeout });
61
+ let connected = false;
62
+
63
+ socket.once('connect', () => {
64
+ connected = true;
65
+ socket.end();
66
+ resolve(true);
67
+ });
68
+
69
+ socket.once('timeout', () => {
70
+ socket.destroy();
71
+ resolve(false);
72
+ });
73
+
74
+ socket.once('error', () => {
75
+ socket.destroy();
76
+ resolve(false);
77
+ });
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Checks if an HTTP readiness endpoint returns a healthy status (2xx).
83
+ *
84
+ * @param {number} port
85
+ * @param {string} [path='/']
86
+ * @param {number} [timeout=1500]
87
+ * @returns {Promise<boolean>}
88
+ */
89
+ checkHttp(port, path = '/', timeout = 1500) {
90
+ return new Promise((resolve) => {
91
+ const req = http.request(
92
+ {
93
+ hostname: '127.0.0.1',
94
+ port,
95
+ path,
96
+ method: 'GET',
97
+ timeout
98
+ },
99
+ (res) => {
100
+ // Read response data to avoid socket leak
101
+ res.resume();
102
+ const is2xx = res.statusCode >= 200 && res.statusCode < 300;
103
+ resolve(is2xx);
104
+ }
105
+ );
106
+
107
+ req.on('timeout', () => {
108
+ req.destroy();
109
+ resolve(false);
110
+ });
111
+
112
+ req.on('error', () => {
113
+ resolve(false);
114
+ });
115
+
116
+ req.end();
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Performs an immediate one-shot readiness check on a generation.
122
+ *
123
+ * @param {object} generation - { id, pid, internalPort }
124
+ * @param {object} [options]
125
+ * @param {string} [options.path]
126
+ * @returns {Promise<boolean>}
127
+ */
128
+ async ensure(generation, options = {}) {
129
+ if (!generation) return false;
130
+
131
+ // 1. Process liveness
132
+ if (generation.pid && this.processManager && !this.processManager.isAlive(generation.pid)) {
133
+ return false;
134
+ }
135
+
136
+ // 2. TCP connectivity
137
+ const tcpOk = await this.checkTcp(generation.internalPort);
138
+ if (!tcpOk) return false;
139
+
140
+ // 3. Optional HTTP endpoint
141
+ if (options.path) {
142
+ const httpOk = await this.checkHttp(generation.internalPort, options.path);
143
+ if (!httpOk) return false;
144
+ }
145
+
146
+ return true;
147
+ }
148
+
149
+ /**
150
+ * Polls readiness until the generation is ready, process crashes, or timeout is reached.
151
+ *
152
+ * @param {object} generation - { id, pid, internalPort }
153
+ * @param {object} [options]
154
+ * @param {number} [options.timeout=15000] - Timeout in milliseconds
155
+ * @param {number} [options.interval=50] - Polling interval in milliseconds
156
+ * @param {string} [options.path] - Optional HTTP health check path (e.g. '/health')
157
+ * @returns {Promise<boolean>} Resolves true when ready
158
+ */
159
+ async wait(generation, options = {}) {
160
+ if (!generation || !generation.internalPort) {
161
+ throw new Error('Valid generation with internalPort is required for readiness check.');
162
+ }
163
+
164
+ const timeout = options.timeout !== undefined ? options.timeout : 15000;
165
+ const interval = options.interval !== undefined ? options.interval : 50;
166
+ const healthPath = options.path;
167
+
168
+ const startTime = Date.now();
169
+
170
+ while (true) {
171
+ // 1. Fail fast if process has crashed during startup
172
+ if (generation.pid && this.processManager && !this.processManager.isAlive(generation.pid)) {
173
+ const procInfo = typeof this.processManager.getProcess === 'function' ? this.processManager.getProcess(generation.pid) : null;
174
+ const stderr = procInfo?.recentStderr || '';
175
+
176
+ if (stderr.includes('EADDRINUSE') || stderr.includes('address already in use')) {
177
+ const portHint = options.configuredPort ? ` (such as configured port :${options.configuredPort})` : '';
178
+ throw new ProcessStartupError(
179
+ `Generation #${generation.id} (PID ${generation.pid}) crashed with EADDRINUSE during startup. ` +
180
+ `The application likely attempted to bind to a hardcoded port${portHint} instead of reading process.env.PORT (${generation.internalPort}).` +
181
+ `\nDetails:\n${stderr.trim()}`,
182
+ generation
183
+ );
184
+ }
185
+
186
+ throw new ProcessStartupError(
187
+ `Generation #${generation.id} (PID ${generation.pid}) process exited unexpectedly during startup.` +
188
+ (stderr ? `\nDetails:\n${stderr.trim()}` : ''),
189
+ generation
190
+ );
191
+ }
192
+
193
+ // 2. Check TCP port
194
+ const tcpReady = await this.checkTcp(generation.internalPort);
195
+ if (tcpReady) {
196
+ // 3. If HTTP health check path is configured, verify HTTP 2xx
197
+ if (healthPath) {
198
+ const httpReady = await this.checkHttp(generation.internalPort, healthPath);
199
+ if (httpReady) {
200
+ return true;
201
+ }
202
+ } else {
203
+ return true;
204
+ }
205
+ } else {
206
+ // Port mismatch fast-detection: If application is listening on configured public port instead of internalPort
207
+ const isRouterOnConfiguredPort =
208
+ options.router && options.router.server && options.router.listeningPort === options.configuredPort;
209
+
210
+ if (!isRouterOnConfiguredPort && options.configuredPort && options.configuredPort !== generation.internalPort) {
211
+ const isConfiguredPortOpen = await this.checkTcp(options.configuredPort);
212
+ if (isConfiguredPortOpen) {
213
+ throw new PortMismatchError(
214
+ `Port mismatch detected: Generation #${generation.id} (PID ${generation.pid}) is listening on configured public port ${options.configuredPort} ` +
215
+ `instead of the dynamically allocated process.env.PORT (${generation.internalPort}). ` +
216
+ `Please ensure your application code reads process.env.PORT (e.g. app.listen(process.env.PORT || ${options.configuredPort})).`,
217
+ generation,
218
+ options.configuredPort
219
+ );
220
+ }
221
+ }
222
+ }
223
+
224
+ // 4. Check timeout
225
+ if (Date.now() - startTime >= timeout) {
226
+ if (generation.pid && this.processManager && this.processManager.isAlive(generation.pid)) {
227
+ throw new ReadinessTimeoutError(
228
+ `Generation #${generation.id} (PID ${generation.pid}) is still running, but did not accept connections on process.env.PORT (${generation.internalPort}) within ${timeout}ms. ` +
229
+ `Ensure your application listens on process.env.PORT (e.g. app.listen(process.env.PORT)).`,
230
+ generation
231
+ );
232
+ }
233
+
234
+ throw new ReadinessTimeoutError(
235
+ `Generation #${generation.id} on port ${generation.internalPort} failed readiness check within ${timeout}ms.`,
236
+ generation
237
+ );
238
+ }
239
+
240
+ await new Promise((resolve) => setTimeout(resolve, interval));
241
+ }
242
+ }
243
+ }