Skip to content

Commit fc81fc3

Browse files
committed
Dispose HTTP server
Shares code with the test HTTP server. For now it is a function but maybe we should make it a class that is extended by tests.
1 parent 51a48ba commit fc81fc3

File tree

8 files changed

+144
-129
lines changed

8 files changed

+144
-129
lines changed

src/common/emitter.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { logger } from "@coder/logger"
77
export type Callback<T, R = void | Promise<void>> = (t: T, p: Promise<void>) => R
88

99
export interface Disposable {
10-
dispose(): void
10+
dispose(): void | Promise<void>
1111
}
1212

1313
export interface Event<T> {

src/node/app.ts

+21-9
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,24 @@ import express, { Express } from "express"
44
import { promises as fs } from "fs"
55
import http from "http"
66
import * as httpolyglot from "httpolyglot"
7+
import { Disposable } from "../common/emitter"
78
import * as util from "../common/util"
89
import { DefaultedArgs } from "./cli"
10+
import { disposer } from "./http"
911
import { isNodeJSErrnoException } from "./util"
1012
import { handleUpgrade } from "./wsRouter"
1113

1214
type ListenOptions = Pick<DefaultedArgs, "socket" | "port" | "host">
1315

16+
export interface App extends Disposable {
17+
/** Handles regular HTTP requests. */
18+
router: Express
19+
/** Handles websocket requests. */
20+
wsRouter: Express
21+
/** The underlying HTTP server. */
22+
server: http.Server
23+
}
24+
1425
const listen = (server: http.Server, { host, port, socket }: ListenOptions) => {
1526
return new Promise<void>(async (resolve, reject) => {
1627
server.on("error", reject)
@@ -41,27 +52,28 @@ const listen = (server: http.Server, { host, port, socket }: ListenOptions) => {
4152
/**
4253
* Create an Express app and an HTTP/S server to serve it.
4354
*/
44-
export const createApp = async (args: DefaultedArgs): Promise<[Express, Express, http.Server]> => {
45-
const app = express()
46-
47-
app.use(compression())
55+
export const createApp = async (args: DefaultedArgs): Promise<App> => {
56+
const router = express()
57+
router.use(compression())
4858

4959
const server = args.cert
5060
? httpolyglot.createServer(
5161
{
5262
cert: args.cert && (await fs.readFile(args.cert.value)),
5363
key: args["cert-key"] && (await fs.readFile(args["cert-key"])),
5464
},
55-
app,
65+
router,
5666
)
57-
: http.createServer(app)
67+
: http.createServer(router)
68+
69+
const dispose = disposer(server)
5870

5971
await listen(server, args)
6072

61-
const wsApp = express()
62-
handleUpgrade(wsApp, server)
73+
const wsRouter = express()
74+
handleUpgrade(wsRouter, server)
6375

64-
return [app, wsApp, server]
76+
return { router, wsRouter, server, dispose }
6577
}
6678

6779
/**

src/node/http.ts

+53
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
import { field, logger } from "@coder/logger"
22
import * as express from "express"
33
import * as expressCore from "express-serve-static-core"
4+
import * as http from "http"
5+
import * as net from "net"
46
import path from "path"
57
import qs from "qs"
8+
import { Disposable } from "../common/emitter"
69
import { HttpCode, HttpError } from "../common/http"
710
import { normalize } from "../common/util"
811
import { AuthType, DefaultedArgs } from "./cli"
@@ -188,3 +191,53 @@ export const getCookieDomain = (host: string, proxyDomains: string[]): string |
188191
logger.debug("got cookie doman", field("host", host))
189192
return host || undefined
190193
}
194+
195+
/**
196+
* Return a function capable of fully disposing an HTTP server.
197+
*/
198+
export function disposer(server: http.Server): Disposable["dispose"] {
199+
const sockets = new Set<net.Socket>()
200+
let cleanupTimeout: undefined | NodeJS.Timeout
201+
202+
server.on("connection", (socket) => {
203+
sockets.add(socket)
204+
205+
socket.on("close", () => {
206+
sockets.delete(socket)
207+
208+
if (cleanupTimeout && sockets.size === 0) {
209+
clearTimeout(cleanupTimeout)
210+
cleanupTimeout = undefined
211+
}
212+
})
213+
})
214+
215+
return () => {
216+
return new Promise<void>((resolve, reject) => {
217+
// The whole reason we need this disposer is because close will not
218+
// actually close anything; it only prevents future connections then waits
219+
// until everything is closed.
220+
server.close((err) => {
221+
if (err) {
222+
return reject(err)
223+
}
224+
225+
resolve()
226+
})
227+
228+
// If there are sockets remaining we might need to force close them or
229+
// this promise might never resolve.
230+
if (sockets.size > 0) {
231+
// Give sockets a chance to close up shop.
232+
cleanupTimeout = setTimeout(() => {
233+
cleanupTimeout = undefined
234+
235+
for (const socket of sockets.values()) {
236+
console.warn("a socket was left hanging")
237+
socket.destroy()
238+
}
239+
}, 1000)
240+
}
241+
})
242+
}
243+
}

src/node/main.ts

+7-6
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,9 @@ export const runCodeServer = async (
121121
)
122122
}
123123

124-
const [app, wsApp, server] = await createApp(args)
125-
const serverAddress = ensureAddress(server, args.cert ? "https" : "http")
126-
const disposeApp = await register(app, wsApp, server, args)
124+
const app = await createApp(args)
125+
const serverAddress = ensureAddress(app.server, args.cert ? "https" : "http")
126+
const disposeRoutes = await register(app, args)
127127

128128
logger.info(`Using config file ${humanPath(args.config)}`)
129129
logger.info(`HTTP server listening on ${serverAddress.toString()} ${args.link ? "(randomized by --link)" : ""}`)
@@ -194,10 +194,11 @@ export const runCodeServer = async (
194194
}
195195

196196
return {
197-
server,
198-
dispose: () => {
199-
disposeApp()
197+
server: app.server,
198+
dispose: async () => {
200199
linkAgent?.kill()
200+
disposeRoutes()
201+
await app.dispose()
201202
},
202203
}
203204
}

src/node/routes/index.ts

+35-40
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import { logger } from "@coder/logger"
22
import cookieParser from "cookie-parser"
33
import * as express from "express"
44
import { promises as fs } from "fs"
5-
import http from "http"
65
import * as path from "path"
76
import * as tls from "tls"
87
import * as pluginapi from "../../../typings/pluginapi"
98
import { Disposable } from "../../common/emitter"
109
import { HttpCode, HttpError } from "../../common/http"
1110
import { plural } from "../../common/util"
11+
import { App } from "../app"
1212
import { AuthType, DefaultedArgs } from "../cli"
1313
import { commit, isDevMode, rootPath } from "../constants"
1414
import { Heart } from "../heart"
@@ -28,15 +28,10 @@ import { createVSServerRouter, VSServerResult } from "./vscode"
2828
/**
2929
* Register all routes and middleware.
3030
*/
31-
export const register = async (
32-
app: express.Express,
33-
wsApp: express.Express,
34-
server: http.Server,
35-
args: DefaultedArgs,
36-
): Promise<Disposable["dispose"]> => {
31+
export const register = async (app: App, args: DefaultedArgs): Promise<Disposable["dispose"]> => {
3732
const heart = new Heart(path.join(paths.data, "heartbeat"), async () => {
3833
return new Promise((resolve, reject) => {
39-
server.getConnections((error, count) => {
34+
app.server.getConnections((error, count) => {
4035
if (error) {
4136
return reject(error)
4237
}
@@ -46,11 +41,11 @@ export const register = async (
4641
})
4742
})
4843

49-
app.disable("x-powered-by")
50-
wsApp.disable("x-powered-by")
44+
app.router.disable("x-powered-by")
45+
app.wsRouter.disable("x-powered-by")
5146

52-
app.use(cookieParser())
53-
wsApp.use(cookieParser())
47+
app.router.use(cookieParser())
48+
app.wsRouter.use(cookieParser())
5449

5550
const common: express.RequestHandler = (req, _, next) => {
5651
// /healthz|/healthz/ needs to be excluded otherwise health checks will make
@@ -66,10 +61,10 @@ export const register = async (
6661
next()
6762
}
6863

69-
app.use(common)
70-
wsApp.use(common)
64+
app.router.use(common)
65+
app.wsRouter.use(common)
7166

72-
app.use(async (req, res, next) => {
67+
app.router.use(async (req, res, next) => {
7368
// If we're handling TLS ensure all requests are redirected to HTTPS.
7469
// TODO: This does *NOT* work if you have a base path since to specify the
7570
// protocol we need to specify the whole path.
@@ -87,24 +82,24 @@ export const register = async (
8782
next()
8883
})
8984

90-
app.use("/", domainProxy.router)
91-
wsApp.use("/", domainProxy.wsRouter.router)
85+
app.router.use("/", domainProxy.router)
86+
app.wsRouter.use("/", domainProxy.wsRouter.router)
9287

93-
app.all("/proxy/(:port)(/*)?", (req, res) => {
88+
app.router.all("/proxy/(:port)(/*)?", (req, res) => {
9489
pathProxy.proxy(req, res)
9590
})
96-
wsApp.get("/proxy/(:port)(/*)?", async (req) => {
91+
app.wsRouter.get("/proxy/(:port)(/*)?", async (req) => {
9792
await pathProxy.wsProxy(req as pluginapi.WebsocketRequest)
9893
})
9994
// These two routes pass through the path directly.
10095
// So the proxied app must be aware it is running
10196
// under /absproxy/<someport>/
102-
app.all("/absproxy/(:port)(/*)?", (req, res) => {
97+
app.router.all("/absproxy/(:port)(/*)?", (req, res) => {
10398
pathProxy.proxy(req, res, {
10499
passthroughPath: true,
105100
})
106101
})
107-
wsApp.get("/absproxy/(:port)(/*)?", async (req) => {
102+
app.wsRouter.get("/absproxy/(:port)(/*)?", async (req) => {
108103
await pathProxy.wsProxy(req as pluginapi.WebsocketRequest, {
109104
passthroughPath: true,
110105
})
@@ -115,40 +110,40 @@ export const register = async (
115110
const workingDir = args._ && args._.length > 0 ? path.resolve(args._[args._.length - 1]) : undefined
116111
pluginApi = new PluginAPI(logger, process.env.CS_PLUGIN, process.env.CS_PLUGIN_PATH, workingDir)
117112
await pluginApi.loadPlugins()
118-
pluginApi.mount(app, wsApp)
119-
app.use("/api/applications", ensureAuthenticated, apps.router(pluginApi))
113+
pluginApi.mount(app.router, app.wsRouter)
114+
app.router.use("/api/applications", ensureAuthenticated, apps.router(pluginApi))
120115
}
121116

122-
app.use(express.json())
123-
app.use(express.urlencoded({ extended: true }))
117+
app.router.use(express.json())
118+
app.router.use(express.urlencoded({ extended: true }))
124119

125-
app.use(
120+
app.router.use(
126121
"/_static",
127122
express.static(rootPath, {
128123
cacheControl: commit !== "development",
129124
}),
130125
)
131126

132-
app.use("/healthz", health.router)
133-
wsApp.use("/healthz", health.wsRouter.router)
127+
app.router.use("/healthz", health.router)
128+
app.wsRouter.use("/healthz", health.wsRouter.router)
134129

135130
if (args.auth === AuthType.Password) {
136-
app.use("/login", login.router)
137-
app.use("/logout", logout.router)
131+
app.router.use("/login", login.router)
132+
app.router.use("/logout", logout.router)
138133
} else {
139-
app.all("/login", (req, res) => redirect(req, res, "/", {}))
140-
app.all("/logout", (req, res) => redirect(req, res, "/", {}))
134+
app.router.all("/login", (req, res) => redirect(req, res, "/", {}))
135+
app.router.all("/logout", (req, res) => redirect(req, res, "/", {}))
141136
}
142137

143-
app.use("/update", update.router)
138+
app.router.use("/update", update.router)
144139

145140
let vscode: VSServerResult
146141
try {
147142
vscode = await createVSServerRouter(args)
148-
app.use("/", vscode.router)
149-
wsApp.use("/", vscode.wsRouter.router)
150-
app.use("/vscode", vscode.router)
151-
wsApp.use("/vscode", vscode.wsRouter.router)
143+
app.router.use("/", vscode.router)
144+
app.wsRouter.use("/", vscode.wsRouter.router)
145+
app.router.use("/vscode", vscode.router)
146+
app.wsRouter.use("/vscode", vscode.wsRouter.router)
152147
} catch (error: any) {
153148
if (isDevMode) {
154149
logger.warn(error)
@@ -158,12 +153,12 @@ export const register = async (
158153
}
159154
}
160155

161-
app.use(() => {
156+
app.router.use(() => {
162157
throw new HttpError("Not Found", HttpCode.NotFound)
163158
})
164159

165-
app.use(errorHandler)
166-
wsApp.use(wsErrorHandler)
160+
app.router.use(errorHandler)
161+
app.wsRouter.use(wsErrorHandler)
167162

168163
return () => {
169164
heart.dispose()

0 commit comments

Comments
 (0)