fix: memory bloat from unlimited JVM heap and missing HTTP Range support
Build & Deploy / build-and-deploy (push) Failing after 35s
Build & Deploy / build-and-deploy (push) Failing after 35s
Two root causes for memory growing to 1.7GB and never shrinking: 1. No -Xmx limit (Dockerfile): JVM grew its heap without bound after processing large uploads and never returned memory to the OS. Now capped at 128MB with G1GC periodic GC every 30s to shrink when idle. 2. Broken HTTP Range (StaticResource): The server advertised Accept-Ranges: bytes but didn't actually handle Range headers — every request sent the entire .jsdos file (up to 1.2GB for GTA). The OS cached the full file in page cache, which counts against Docker container memory and persists after play stops. js-dos v7 loads .jsdos via Range requests (lazy ZIP loading). Now properly parses Range: bytes=start-end headers, responds with 206 Partial Content, and seeks to exact offsets in the file — only the requested bytes stream through, keeping page cache minimal.
This commit is contained in:
+4
-1
@@ -25,4 +25,7 @@ COPY --from=build /build/target/quarkus-app/ /app/
|
||||
EXPOSE 8765
|
||||
VOLUME /data
|
||||
ENV DOSTALGIA_DATA_DIR=/data
|
||||
ENTRYPOINT ["java", "-jar", "quarkus-run.jar"]
|
||||
# Limit JVM heap — the app streams everything (upload/serve) so 128MB is plenty
|
||||
# G1GC with periodic GC allows heap to shrink when idle
|
||||
ENTRYPOINT ["java", "-Xmx128m", "-Xms64m", "-XX:+UseG1GC", "-XX:G1PeriodicGCInterval=30000",
|
||||
"-jar", "quarkus-run.jar"]
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.dostalgia;
|
||||
|
||||
import jakarta.inject.Inject;
|
||||
import jakarta.ws.rs.GET;
|
||||
import jakarta.ws.rs.HeaderParam;
|
||||
import jakarta.ws.rs.PathParam;
|
||||
import jakarta.ws.rs.core.MediaType;
|
||||
import jakarta.ws.rs.core.Response;
|
||||
@@ -9,8 +10,11 @@ import jakarta.ws.rs.core.StreamingOutput;
|
||||
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||
|
||||
import java.io.*;
|
||||
import java.nio.channels.FileChannel;
|
||||
import java.nio.file.*;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
/** Serves game bundles (.jsdos) and artwork from the external data directory. */
|
||||
@jakarta.ws.rs.Path("/")
|
||||
@@ -19,42 +23,28 @@ public class StaticResource {
|
||||
@ConfigProperty(name = "dostalgia.data.dir", defaultValue = "data")
|
||||
String dataDir;
|
||||
|
||||
/** Pattern for HTTP Range header: bytes=start-end */
|
||||
private static final Pattern RANGE_PATTERN = Pattern.compile(
|
||||
"^bytes=(\\d+)-(\\d*)$", Pattern.CASE_INSENSITIVE
|
||||
);
|
||||
|
||||
@GET
|
||||
@jakarta.ws.rs.Path("/games/{path: .*}")
|
||||
public Response getGameFile(@PathParam("path") String path) {
|
||||
Path file = Path.of(dataDir, "games", path).normalize();
|
||||
Path base = Path.of(dataDir, "games").normalize();
|
||||
if (!file.startsWith(base)) {
|
||||
return Response.status(403).build();
|
||||
}
|
||||
if (!Files.exists(file) || Files.isDirectory(file)) {
|
||||
return Response.status(404).build();
|
||||
}
|
||||
|
||||
String contentType = guessContentType(file.getFileName().toString());
|
||||
return Response.ok(new FileStream(file))
|
||||
.type(contentType)
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.build();
|
||||
public Response getGameFile(
|
||||
@PathParam("path") String path,
|
||||
@HeaderParam("Range") String rangeHeader
|
||||
) {
|
||||
return serveFile(Path.of(dataDir, "games", path), rangeHeader, null);
|
||||
}
|
||||
|
||||
@GET
|
||||
@jakarta.ws.rs.Path("/artwork/{path: .*}")
|
||||
public Response getArtwork(@PathParam("path") String path) {
|
||||
Path file = Path.of(dataDir, "artwork", path).normalize();
|
||||
Path base = Path.of(dataDir, "artwork").normalize();
|
||||
if (!file.startsWith(base)) {
|
||||
return Response.status(403).build();
|
||||
}
|
||||
if (!Files.exists(file) || Files.isDirectory(file)) {
|
||||
return Response.status(404).build();
|
||||
}
|
||||
|
||||
String contentType = guessContentType(file.getFileName().toString());
|
||||
return Response.ok(new FileStream(file))
|
||||
.type(contentType)
|
||||
.header("Cache-Control", "public, max-age=86400")
|
||||
.build();
|
||||
public Response getArtwork(
|
||||
@PathParam("path") String path,
|
||||
@HeaderParam("Range") String rangeHeader
|
||||
) {
|
||||
return serveFile(Path.of(dataDir, "artwork", path), rangeHeader,
|
||||
"public, max-age=86400");
|
||||
}
|
||||
|
||||
/** Health check */
|
||||
@@ -64,6 +54,95 @@ public class StaticResource {
|
||||
return Response.ok(Map.of("status", "ok", "version", "0.1.0")).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve a file with optional HTTP Range support.
|
||||
* <p>
|
||||
* js-dos v7 uses Range requests to lazily load ZIP entries from .jsdos
|
||||
* bundles. Without proper Range handling, the entire multi-GB file gets
|
||||
* streamed to the browser and cached in the OS page cache, keeping memory
|
||||
* high long after play ends.
|
||||
*/
|
||||
private Response serveFile(Path file, String rangeHeader, String cacheControl) {
|
||||
Path base = file.getParent().normalize();
|
||||
file = file.normalize();
|
||||
if (!file.startsWith(base)) {
|
||||
return Response.status(403).build();
|
||||
}
|
||||
if (!Files.exists(file) || Files.isDirectory(file)) {
|
||||
return Response.status(404).build();
|
||||
}
|
||||
|
||||
String contentType = guessContentType(file.getFileName().toString());
|
||||
long fileSize;
|
||||
try {
|
||||
fileSize = Files.size(file);
|
||||
} catch (IOException e) {
|
||||
return Response.serverError().build();
|
||||
}
|
||||
|
||||
// No Range header — serve the full file (200 OK)
|
||||
if (rangeHeader == null || rangeHeader.isBlank()) {
|
||||
Response.ResponseBuilder rb = Response.ok(new FileStream(file, 0, fileSize))
|
||||
.type(contentType)
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.header("Content-Length", fileSize);
|
||||
if (cacheControl != null) {
|
||||
rb.header("Cache-Control", cacheControl);
|
||||
}
|
||||
return rb.build();
|
||||
}
|
||||
|
||||
// Parse Range header: bytes=start-end
|
||||
Matcher m = RANGE_PATTERN.matcher(rangeHeader.trim());
|
||||
if (!m.matches()) {
|
||||
// Unrecognised range format — serve full file
|
||||
return serveFull(file, contentType, fileSize, cacheControl);
|
||||
}
|
||||
|
||||
long start = Long.parseLong(m.group(1));
|
||||
long end;
|
||||
if (m.group(2).isEmpty()) {
|
||||
// Open-ended: bytes=N- → serve from N to end
|
||||
end = fileSize - 1;
|
||||
} else {
|
||||
end = Long.parseLong(m.group(2));
|
||||
}
|
||||
|
||||
// Validate
|
||||
if (start >= fileSize || end < start) {
|
||||
return Response.status(416)
|
||||
.header("Content-Range", "bytes */" + fileSize)
|
||||
.build();
|
||||
}
|
||||
if (end >= fileSize) {
|
||||
end = fileSize - 1;
|
||||
}
|
||||
|
||||
long contentLength = end - start + 1;
|
||||
Response.ResponseBuilder rb = Response.status(206)
|
||||
.entity(new FileStream(file, start, contentLength))
|
||||
.type(contentType)
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.header("Content-Range", "bytes " + start + "-" + end + "/" + fileSize)
|
||||
.header("Content-Length", contentLength);
|
||||
if (cacheControl != null) {
|
||||
rb.header("Cache-Control", cacheControl);
|
||||
}
|
||||
return rb.build();
|
||||
}
|
||||
|
||||
/** Convenience: full-file 200 response. */
|
||||
private Response serveFull(Path file, String contentType, long fileSize, String cacheControl) {
|
||||
Response.ResponseBuilder rb = Response.ok(new FileStream(file, 0, fileSize))
|
||||
.type(contentType)
|
||||
.header("Accept-Ranges", "bytes")
|
||||
.header("Content-Length", fileSize);
|
||||
if (cacheControl != null) {
|
||||
rb.header("Cache-Control", cacheControl);
|
||||
}
|
||||
return rb.build();
|
||||
}
|
||||
|
||||
private String guessContentType(String name) {
|
||||
String n = name.toLowerCase();
|
||||
if (n.endsWith(".jsdos")) return "application/zip";
|
||||
@@ -80,11 +159,28 @@ public class StaticResource {
|
||||
return MediaType.APPLICATION_OCTET_STREAM;
|
||||
}
|
||||
|
||||
/** Streaming file output that avoids loading the whole file into memory. */
|
||||
private record FileStream(Path file) implements StreamingOutput {
|
||||
/**
|
||||
* Streaming file output that avoids loading the whole file into memory.
|
||||
* Supports partial reads (seek + limit) for HTTP Range requests.
|
||||
*/
|
||||
private record FileStream(Path file, long offset, long length) implements StreamingOutput {
|
||||
@Override
|
||||
public void write(OutputStream output) throws IOException {
|
||||
Files.copy(file, output);
|
||||
try (var raf = new RandomAccessFile(file.toFile(), "r");
|
||||
var channel = raf.getChannel()) {
|
||||
if (offset > 0) {
|
||||
raf.seek(offset);
|
||||
}
|
||||
long remaining = length;
|
||||
byte[] buf = new byte[8192];
|
||||
while (remaining > 0) {
|
||||
int toRead = (int) Math.min(buf.length, remaining);
|
||||
int read = raf.read(buf, 0, toRead);
|
||||
if (read < 0) break;
|
||||
output.write(buf, 0, read);
|
||||
remaining -= read;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user